diff --git a/package.json b/package.json index 02144b24..d5da97d9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "next:serve": "yarn workspace @se-2/nextjs serve", "precommit": "lint-staged", "start:frontend": "yarn workspace @polypay/frontend dev", - "start:backend": "yarn workspace @polypay/backend start", + "start:backend": "yarn workspace @polypay/backend start:dev", "typecheck:shared": "yarn workspace @polypay/shared typecheck", "test": "yarn hardhat:test", "vercel": "yarn workspace @se-2/nextjs vercel", diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 930dcd62..54b30bf5 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -32,3 +32,7 @@ NETWORK="testnet" # User for analytics ADMIN_API_KEY=admin-key PARTNER_API_KEY=partner-key + +# Telegram alerts (optional - for relayer balance monitoring) +TELEGRAM_BOT_TOKEN="your-telegram-bot-token-here" +TELEGRAM_CHAT_ID="your-telegram-chat-id-here" diff --git a/packages/backend/nest-cli.json b/packages/backend/nest-cli.json index 083f7cad..e6f19d56 100644 --- a/packages/backend/nest-cli.json +++ b/packages/backend/nest-cli.json @@ -5,6 +5,7 @@ "compilerOptions": { "deleteOutDir": true, "webpack": true, + "webpackConfigPath": "webpack.config.js", "tsConfigPath": "tsconfig.json" } } diff --git a/packages/backend/package.json b/packages/backend/package.json index e00b7e2b..25160b8c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,6 +35,7 @@ "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^8.0.7", "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.5.0", "@nestjs/websockets": "^11.1.10", "@noir-lang/noir_js": "1.0.0-beta.6", "@polypay/shared": "1.0.0", diff --git a/packages/backend/src/account/account.service.ts b/packages/backend/src/account/account.service.ts index 97c66c52..1b173850 100644 --- a/packages/backend/src/account/account.service.ts +++ b/packages/backend/src/account/account.service.ts @@ -298,20 +298,9 @@ export class AccountService { } } - // Return normalized response objects (similar to findByAddress) - return createdAccounts.map((account) => ({ - id: account.id, - address: account.address, - name: account.name, - threshold: account.threshold, - chainId: account.chainId, - createdAt: account.createdAt, - signers: account.signers.map((as) => ({ - commitment: as.user.commitment, - name: as.displayName, - isCreator: as.isCreator, - })), - })); + return createdAccounts.map((account) => + this.formatAccountResponse(account), + ); } /** @@ -333,19 +322,7 @@ export class AccountService { throw new NotFoundException('Account not found'); } - return { - id: account.id, - address: account.address, - name: account.name, - threshold: account.threshold, - chainId: account.chainId, - createdAt: account.createdAt, - signers: account.signers.map((as) => ({ - commitment: as.user.commitment, - name: as.displayName, - isCreator: as.isCreator, - })), - }; + return this.formatAccountResponse(account); } /** @@ -363,7 +340,23 @@ export class AccountService { orderBy: { createdAt: 'desc' }, }); - return accounts.map((account) => ({ + return accounts.map((account) => this.formatAccountResponse(account)); + } + + private formatAccountResponse(account: { + id: string; + address: string; + name: string | null; + threshold: number; + chainId: number; + createdAt: Date; + signers: Array<{ + isCreator: boolean; + displayName: string | null; + user: { commitment: string }; + }>; + }) { + return { id: account.id, address: account.address, name: account.name, @@ -375,7 +368,7 @@ export class AccountService { name: as.displayName, isCreator: as.isCreator, })), - })); + }; } /** diff --git a/packages/backend/src/admin/admin.service.ts b/packages/backend/src/admin/admin.service.ts index 0050f9e7..6020e2da 100644 --- a/packages/backend/src/admin/admin.service.ts +++ b/packages/backend/src/admin/admin.service.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'; import { PrismaService } from '@/database/prisma.service'; import { TxType } from '@/generated/prisma/client'; import { AnalyticsReportDto } from './dto/analytics-report.dto'; +import { EXPLORER_URLS } from '@/common/constants/campaign'; +import { TxStatus, VoteType } from '@polypay/shared'; interface AnalyticsRecord { timestamp: Date; @@ -25,22 +27,9 @@ export class AdminService { private readonly configService: ConfigService, ) { const network = this.configService.get('NETWORK') || 'mainnet'; - - const configs = { - mainnet: { - ZKVERIFY_EXPLORER: 'https://zkverify.subscan.io/tx', - HORIZEN_EXPLORER_ADDRESS: 'https://horizen.calderaexplorer.xyz/address', - HORIZEN_EXPLORER_TX: 'https://horizen.calderaexplorer.xyz/tx', - }, - testnet: { - ZKVERIFY_EXPLORER: 'https://zkverify-testnet.subscan.io/tx', - HORIZEN_EXPLORER_ADDRESS: - 'https://horizen-testnet.explorer.caldera.xyz/address', - HORIZEN_EXPLORER_TX: 'https://horizen-testnet.explorer.caldera.xyz/tx', - }, - }; - - this.explorerConfig = configs[network] || configs.mainnet; + this.explorerConfig = + EXPLORER_URLS[network as keyof typeof EXPLORER_URLS] || + EXPLORER_URLS.mainnet; } /** @@ -79,6 +68,26 @@ export class AdminService { } } + /** + * Build commitment → walletAddress map from loginHistory (batch query) + */ + private async buildCommitmentToAddressMap( + commitments: string[], + ): Promise> { + if (commitments.length === 0) return new Map(); + + const uniqueCommitments = [...new Set(commitments)]; + const loginHistories = await this.prisma.loginHistory.findMany({ + where: { commitment: { in: uniqueCommitments } }, + orderBy: { createdAt: 'desc' }, + distinct: ['commitment'], + }); + + return new Map( + loginHistories.map((lh) => [lh.commitment, lh.walletAddress]), + ); + } + async generateAnalyticsReport(dto?: AnalyticsReportDto): Promise { const records: AnalyticsRecord[] = []; @@ -122,36 +131,66 @@ export class AdminService { orderBy: { createdAt: 'asc' }, }); + // Batch load wallet addresses for account creators + const creatorCommitments = accounts + .map((a) => a.signers[0]?.user.commitment) + .filter(Boolean); + + // 3. APPROVE votes + const approveVotes = await this.prisma.vote.findMany({ + where: { + voteType: VoteType.APPROVE, + ...(hasDateFilter ? { createdAt: dateFilter } : {}), + }, + include: { transaction: true }, + orderBy: { createdAt: 'asc' }, + }); + + // 4. DENY votes + const denyVotes = dto?.includeDeny + ? await this.prisma.vote.findMany({ + where: { + voteType: VoteType.DENY, + ...(hasDateFilter ? { createdAt: dateFilter } : {}), + }, + include: { transaction: true }, + orderBy: { createdAt: 'asc' }, + }) + : []; + + // 5. EXECUTE records + const executedTxs = await this.prisma.transaction.findMany({ + where: { + status: TxStatus.EXECUTED, + ...(hasDateFilter ? { executedAt: dateFilter } : {}), + }, + orderBy: { executedAt: 'asc' }, + }); + + // Batch load all commitment → walletAddress mappings in one query + const allCommitments = [ + ...creatorCommitments, + ...approveVotes.map((v) => v.voterCommitment), + ...denyVotes.map((v) => v.voterCommitment), + ...executedTxs.map((tx) => tx.createdBy), + ]; + const addressMap = await this.buildCommitmentToAddressMap(allCommitments); + + // Process accounts for (const account of accounts) { const creator = account.signers[0]; if (!creator) continue; - // Get wallet address from LoginHistory - const loginHistory = await this.prisma.loginHistory.findFirst({ - where: { commitment: creator.user.commitment }, - orderBy: { createdAt: 'desc' }, - }); - records.push({ timestamp: account.createdAt, action: 'CREATE_ACCOUNT', - userAddress: loginHistory?.walletAddress || 'UNKNOWN', + userAddress: addressMap.get(creator.user.commitment) || 'UNKNOWN', multisigWallet: account.address, txHash: account.address, }); } - // 3. APPROVE votes (includes TRANSFER, BATCH_TRANSFER, ADD_SIGNER, etc.) - const approveVotes = await this.prisma.vote.findMany({ - where: { - voteType: 'APPROVE', - ...(hasDateFilter ? { createdAt: dateFilter } : {}), - }, - include: { transaction: true }, - orderBy: { createdAt: 'asc' }, - }); - - // Group votes by txId to find first vote (proposer) + // Process approve votes - group by txId to find first vote (proposer) const votesByTx: Record = {}; for (const vote of approveVotes) { if (!votesByTx[vote.txId]) { @@ -160,7 +199,6 @@ export class AdminService { votesByTx[vote.txId].push(vote); } - // Sort each group by createdAt and determine action for (const txId in votesByTx) { const votes = votesByTx[txId].sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), @@ -168,55 +206,31 @@ export class AdminService { for (let i = 0; i < votes.length; i++) { const vote = votes[i]; - const isFirstVote = i === 0; - - // First vote = propose action (TRANSFER, ADD_SIGNER, etc.) - // Subsequent votes = APPROVE - const action = isFirstVote - ? this.mapTxTypeToAction(vote.transaction.type) - : 'APPROVE'; - - const loginHistory = await this.prisma.loginHistory.findFirst({ - where: { commitment: vote.voterCommitment }, - orderBy: { createdAt: 'desc' }, - }); + const action = + i === 0 ? this.mapTxTypeToAction(vote.transaction.type) : 'APPROVE'; records.push({ timestamp: vote.createdAt, - action: action, - userAddress: loginHistory?.walletAddress || 'UNKNOWN', + action, + userAddress: addressMap.get(vote.voterCommitment) || 'UNKNOWN', multisigWallet: vote.transaction.accountAddress, txHash: vote.zkVerifyTxHash || 'PENDING', }); } } - if (dto?.includeDeny) { - const denyVotes = await this.prisma.vote.findMany({ - where: { - voteType: 'DENY', - ...(hasDateFilter ? { createdAt: dateFilter } : {}), - }, - include: { transaction: true }, - orderBy: { createdAt: 'asc' }, + // Process deny votes + for (const vote of denyVotes) { + records.push({ + timestamp: vote.createdAt, + action: 'DENY', + userAddress: addressMap.get(vote.voterCommitment) || 'UNKNOWN', + multisigWallet: vote.transaction.accountAddress, + txHash: null, }); - - for (const vote of denyVotes) { - const loginHistory = await this.prisma.loginHistory.findFirst({ - where: { commitment: vote.voterCommitment }, - orderBy: { createdAt: 'desc' }, - }); - - records.push({ - timestamp: vote.createdAt, - action: 'DENY', - userAddress: loginHistory?.walletAddress || 'UNKNOWN', - multisigWallet: vote.transaction.accountAddress, - txHash: null, - }); - } } + // Process claims if (dto?.includeClaim) { const claimHistories = await this.prisma.claimHistory.findMany({ where: hasDateFilter ? { createdAt: dateFilter } : undefined, @@ -234,25 +248,12 @@ export class AdminService { } } - // 5. EXECUTE records - const executedTxs = await this.prisma.transaction.findMany({ - where: { - status: 'EXECUTED', - ...(hasDateFilter ? { executedAt: dateFilter } : {}), - }, - orderBy: { executedAt: 'asc' }, - }); - + // Process executed transactions for (const tx of executedTxs) { - const loginHistory = await this.prisma.loginHistory.findFirst({ - where: { commitment: tx.createdBy }, - orderBy: { createdAt: 'desc' }, - }); - records.push({ timestamp: tx.executedAt || tx.updatedAt, action: 'EXECUTE', - userAddress: loginHistory?.walletAddress || 'UNKNOWN', + userAddress: addressMap.get(tx.createdBy) || 'UNKNOWN', multisigWallet: tx.accountAddress, txHash: tx.txHash || 'PENDING', }); diff --git a/packages/backend/src/app.module.ts b/packages/backend/src/app.module.ts index cef4dc1d..dff5610b 100644 --- a/packages/backend/src/app.module.ts +++ b/packages/backend/src/app.module.ts @@ -1,4 +1,5 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { ThrottlerModule } from '@nestjs/throttler'; import { ConfigModule } from '@/config/config.module'; import { IpRestrictMiddleware } from '@/common/middleware/ip-restrict.middleware'; import { DatabaseModule } from '@/database/database.module'; @@ -18,11 +19,15 @@ import { AdminModule } from './admin/admin.module'; import { PartnerModule } from './partner/partner.module'; import { QuestModule } from './quest/quest.module'; import { RewardModule } from './reward/reward.module'; +import { BalanceAlertModule } from './balance-alert/balance-alert.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ ConfigModule, + ThrottlerModule.forRoot({ + throttlers: [{ ttl: 60_000, limit: 60 }], + }), DatabaseModule, ZkVerifyModule, TransactionModule, @@ -40,6 +45,7 @@ import { ScheduleModule } from '@nestjs/schedule'; PartnerModule, QuestModule, RewardModule, + BalanceAlertModule, ScheduleModule.forRoot(), ], }) diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 683525c6..1c0aac2d 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -1,10 +1,12 @@ -import { Controller, Post, Body } from '@nestjs/common'; +import { Controller, Post, Body, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { ThrottlerGuard, Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { LoginDto, RefreshDto } from '@polypay/shared'; @ApiTags('auth') @Controller('auth') +@UseGuards(ThrottlerGuard) export class AuthController { constructor(private readonly authService: AuthService) {} @@ -13,6 +15,7 @@ export class AuthController { * POST /api/auth/login */ @Post('login') + @Throttle({ default: { ttl: 60_000, limit: 5 } }) @ApiOperation({ summary: 'Login with zero-knowledge proof', description: @@ -65,6 +68,7 @@ export class AuthController { * POST /api/auth/refresh */ @Post('refresh') + @Throttle({ default: { ttl: 60_000, limit: 30 } }) @ApiOperation({ summary: 'Refresh access token', description: diff --git a/packages/backend/src/auth/auth.service.ts b/packages/backend/src/auth/auth.service.ts index 7ad63dfe..3e49fed6 100644 --- a/packages/backend/src/auth/auth.service.ts +++ b/packages/backend/src/auth/auth.service.ts @@ -1,9 +1,4 @@ -import { - Injectable, - UnauthorizedException, - Logger, - BadRequestException, -} from '@nestjs/common'; +import { Injectable, UnauthorizedException, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ZkVerifyService } from '@/zkverify/zkverify.service'; import { LoginDto, RefreshDto } from '@polypay/shared'; @@ -34,6 +29,7 @@ export class AuthService { let proofResult; try { + // submitProofAndWaitFinalized throws on failure proofResult = await this.zkVerifyService.submitProofAndWaitFinalized( { proof: dto.proof, @@ -42,10 +38,6 @@ export class AuthService { }, 'auth', ); - - if (proofResult.status === 'Failed') { - throw new BadRequestException('Proof verification failed'); - } } catch (error) { this.logger.error(`Proof verification failed: ${error.message}`); throw new UnauthorizedException('Invalid proof'); diff --git a/packages/backend/src/auth/guards/admin.guard.ts b/packages/backend/src/auth/guards/admin.guard.ts index 4bf33108..8a6b9c14 100644 --- a/packages/backend/src/auth/guards/admin.guard.ts +++ b/packages/backend/src/auth/guards/admin.guard.ts @@ -5,6 +5,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { timingSafeEqual } from 'crypto'; @Injectable() export class AdminGuard implements CanActivate { @@ -12,7 +13,7 @@ export class AdminGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - const apiKey = request.headers['x-admin-key']; + const apiKey = request.headers['x-admin-key'] as string | undefined; const adminApiKey = this.configService.get('ADMIN_API_KEY'); @@ -20,7 +21,11 @@ export class AdminGuard implements CanActivate { throw new UnauthorizedException('Admin API key not configured'); } - if (!apiKey || apiKey !== adminApiKey) { + if ( + !apiKey || + apiKey.length !== adminApiKey.length || + !timingSafeEqual(Buffer.from(apiKey), Buffer.from(adminApiKey)) + ) { throw new UnauthorizedException('Invalid admin API key'); } diff --git a/packages/backend/src/auth/guards/partner.guard.ts b/packages/backend/src/auth/guards/partner.guard.ts index 2ee756d3..b3be1695 100644 --- a/packages/backend/src/auth/guards/partner.guard.ts +++ b/packages/backend/src/auth/guards/partner.guard.ts @@ -5,6 +5,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { timingSafeEqual } from 'crypto'; @Injectable() export class PartnerGuard implements CanActivate { @@ -12,7 +13,7 @@ export class PartnerGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - const apiKey = request.headers['x-partner-key']; + const apiKey = request.headers['x-partner-key'] as string | undefined; const partnerApiKey = this.configService.get('PARTNER_API_KEY'); @@ -20,7 +21,11 @@ export class PartnerGuard implements CanActivate { throw new UnauthorizedException('Partner API key not configured'); } - if (!apiKey || apiKey !== partnerApiKey) { + if ( + !apiKey || + apiKey.length !== partnerApiKey.length || + !timingSafeEqual(Buffer.from(apiKey), Buffer.from(partnerApiKey)) + ) { throw new UnauthorizedException('Invalid partner API key'); } diff --git a/packages/backend/src/balance-alert/balance-alert.module.ts b/packages/backend/src/balance-alert/balance-alert.module.ts new file mode 100644 index 00000000..e583ae74 --- /dev/null +++ b/packages/backend/src/balance-alert/balance-alert.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { BalanceAlertScheduler } from './balance-alert.scheduler'; +import { TelegramService } from './telegram.service'; + +@Module({ + providers: [TelegramService, BalanceAlertScheduler], + exports: [TelegramService], +}) +export class BalanceAlertModule {} diff --git a/packages/backend/src/balance-alert/balance-alert.scheduler.ts b/packages/backend/src/balance-alert/balance-alert.scheduler.ts new file mode 100644 index 00000000..adebd8a1 --- /dev/null +++ b/packages/backend/src/balance-alert/balance-alert.scheduler.ts @@ -0,0 +1,124 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { createPublicClient, http, formatEther } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { base, baseSepolia } from 'viem/chains'; +import { CONFIG_KEYS } from '@/config/config.keys'; +import { getChain } from '@polypay/shared'; +import { TelegramService } from './telegram.service'; + +interface ChainCheck { + name: string; + chain: any; + threshold: bigint; +} + +@Injectable() +export class BalanceAlertScheduler { + private readonly logger = new Logger(BalanceAlertScheduler.name); + private readonly walletAddress: string; + private readonly chains: ChainCheck[]; + + constructor( + private readonly configService: ConfigService, + private readonly telegramService: TelegramService, + ) { + const privateKey = this.configService.get( + CONFIG_KEYS.RELAYER_WALLET_KEY, + ) as `0x${string}`; + + if (!privateKey) { + throw new Error('RELAYER_WALLET_KEY is not set'); + } + + const account = privateKeyToAccount(privateKey); + this.walletAddress = account.address; + + const network = this.configService.get(CONFIG_KEYS.APP_NETWORK); + const isMainnet = network === 'mainnet'; + + this.chains = [ + { + name: isMainnet ? 'Horizen Mainnet' : 'Horizen Testnet', + chain: getChain(network as 'mainnet' | 'testnet'), + threshold: 100000000000000n, // 0.0001 ETH + }, + { + name: isMainnet ? 'Base Mainnet' : 'Base Sepolia', + chain: isMainnet ? base : baseSepolia, + threshold: 500000000000000n, // 0.0005 ETH + }, + ]; + + this.logger.log( + `Balance alert initialized for relayer: ${this.walletAddress}`, + ); + } + + // 7:00 AM Vietnam (00:00 UTC) + @Cron('0 0 * * *', { timeZone: 'UTC' }) + async checkBalances() { + this.logger.log('Running daily relayer balance check'); + + const alerts: string[] = []; + + for (const { name, chain, threshold } of this.chains) { + try { + const client = createPublicClient({ + chain, + transport: http(), + }); + + const balance = await client.getBalance({ + address: this.walletAddress as `0x${string}`, + }); + + const balanceFormatted = formatEther(balance); + const thresholdFormatted = formatEther(threshold); + + if (balance < threshold) { + this.logger.warn( + `Low balance on ${name}: ${balanceFormatted} ETH (threshold: ${thresholdFormatted} ETH)`, + ); + + const balanceShort = parseFloat(balanceFormatted).toFixed(6); + const thresholdShort = parseFloat(thresholdFormatted).toFixed(4); + + alerts.push( + `šŸ”“ ${name}\n` + + ` ${balanceShort} / ${thresholdShort} ETH`, + ); + } else { + this.logger.log(`${name} balance OK: ${balanceFormatted} ETH`); + } + } catch (error) { + this.logger.error( + `Failed to check balance on ${name}: ${error.message}`, + ); + alerts.push( + `${name}\nFailed to check balance: ${error.message}`, + ); + } + } + + if (alerts.length > 0) { + const now = new Date().toLocaleString('vi-VN', { + timeZone: 'Asia/Ho_Chi_Minh', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + const message = + `āš ļø PolyPay Relayer — Low Balance\n\n` + + `šŸ”‘ ${this.walletAddress}\n\n` + + alerts.join('\n\n') + + `\n\nā° ${now} (UTC+7)`; + + await this.telegramService.sendMessage(message); + } + } +} diff --git a/packages/backend/src/balance-alert/telegram.service.ts b/packages/backend/src/balance-alert/telegram.service.ts new file mode 100644 index 00000000..df1b1d2a --- /dev/null +++ b/packages/backend/src/balance-alert/telegram.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class TelegramService { + private readonly logger = new Logger(TelegramService.name); + private readonly botToken: string | undefined; + private readonly chatId: string | undefined; + private readonly maxRetries = 3; + + constructor(private readonly configService: ConfigService) { + this.botToken = this.configService.get('telegram.botToken'); + this.chatId = this.configService.get('telegram.chatId'); + + if (!this.botToken || !this.chatId) { + this.logger.warn( + 'TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is not set. Telegram alerts are disabled.', + ); + } + } + + async sendMessage(message: string): Promise { + if (!this.botToken || !this.chatId) { + this.logger.warn('Telegram not configured, skipping alert'); + return; + } + + const url = `https://api.telegram.org/bot${this.botToken}/sendMessage`; + + for (let attempt = 1; attempt <= this.maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: this.chatId, + text: message, + parse_mode: 'HTML', + }), + }); + + if (response.ok) { + this.logger.log('Telegram alert sent successfully'); + return; + } + + const body = await response.text(); + const status = response.status; + + // Don't retry on 4xx errors (except 429 rate limit) — these are config issues + if (status >= 400 && status < 500 && status !== 429) { + this.logger.error( + `Telegram API error (${status}), not retrying: ${body}`, + ); + return; + } + + throw new Error(`Telegram API error: ${status} - ${body}`); + } catch (error) { + this.logger.error( + `Failed to send Telegram alert (attempt ${attempt}/${this.maxRetries}): ${error.message}`, + ); + + if (attempt < this.maxRetries) { + const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s + this.logger.log(`Retrying in ${delay / 1000}s...`); + await this.sleep(delay); + } else { + this.logger.error( + `All ${this.maxRetries} attempts failed. Telegram alert not sent.`, + ); + } + } + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/backend/src/common/analytics-logger.service.ts b/packages/backend/src/common/analytics-logger.service.ts index b49ae31b..22d1ee2c 100644 --- a/packages/backend/src/common/analytics-logger.service.ts +++ b/packages/backend/src/common/analytics-logger.service.ts @@ -1,6 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; +import { + ANALYTICS_LOG_WRITE_DELAY_MAX, + ADDRESS_LOG_PREVIEW_LENGTH, +} from './constants/timing'; @Injectable() export class AnalyticsLoggerService { @@ -23,7 +27,7 @@ export class AnalyticsLoggerService { * Write log to local file (for local development) */ private writeFileLog(logEntry: string, debugMsg: string) { - const delay = Math.floor(Math.random() * 500); + const delay = Math.floor(Math.random() * ANALYTICS_LOG_WRITE_DELAY_MAX); setTimeout(() => { try { @@ -66,7 +70,7 @@ export class AnalyticsLoggerService { this.writeFileLog( logEntry, - `Logged LOGIN: ${walletAddress.substring(0, 10)}...`, + `Logged LOGIN: ${walletAddress.substring(0, ADDRESS_LOG_PREVIEW_LENGTH)}...`, ); this.writeCloudLog({ @@ -84,7 +88,7 @@ export class AnalyticsLoggerService { this.writeFileLog( logEntry, - `Logged CREATE_ACCOUNT: ${addr.substring(0, 10)}... | ${accountAddress.substring(0, 10)}...`, + `Logged CREATE_ACCOUNT: ${addr.substring(0, ADDRESS_LOG_PREVIEW_LENGTH)}... | ${accountAddress.substring(0, ADDRESS_LOG_PREVIEW_LENGTH)}...`, ); this.writeCloudLog({ @@ -108,7 +112,7 @@ export class AnalyticsLoggerService { this.writeFileLog( logEntry, - `Logged ${action}: ${addr.substring(0, 10)}... | ${accountAddress.substring(0, 10)}...`, + `Logged ${action}: ${addr.substring(0, ADDRESS_LOG_PREVIEW_LENGTH)}... | ${accountAddress.substring(0, ADDRESS_LOG_PREVIEW_LENGTH)}...`, ); this.writeCloudLog({ diff --git a/packages/backend/src/common/constants/campaign.ts b/packages/backend/src/common/constants/campaign.ts index 2f3f4af5..bec72fe3 100644 --- a/packages/backend/src/common/constants/campaign.ts +++ b/packages/backend/src/common/constants/campaign.ts @@ -9,3 +9,29 @@ export const ZEN_DECIMALS = 18; // ZEN CoinGecko ID export const ZEN_COINGECKO_ID = 'zencash'; + +// Quest points +export const QUEST_POINTS_ACCOUNT_FIRST_TX = 100; +export const QUEST_POINTS_SUCCESSFUL_TX = 50; + +// Supported chain IDs +export const SUPPORTED_CHAIN_IDS = [2651420, 84532, 26514, 8453]; + +// External APIs +export const COINGECKO_API_URL = + 'https://api.coingecko.com/api/v3/simple/price'; + +// Explorer URLs +export const EXPLORER_URLS = { + mainnet: { + ZKVERIFY_EXPLORER: 'https://zkverify.subscan.io/tx', + HORIZEN_EXPLORER_ADDRESS: 'https://horizen.calderaexplorer.xyz/address', + HORIZEN_EXPLORER_TX: 'https://horizen.calderaexplorer.xyz/tx', + }, + testnet: { + ZKVERIFY_EXPLORER: 'https://zkverify-testnet.subscan.io/tx', + HORIZEN_EXPLORER_ADDRESS: + 'https://horizen-testnet.explorer.caldera.xyz/address', + HORIZEN_EXPLORER_TX: 'https://horizen-testnet.explorer.caldera.xyz/tx', + }, +} as const; diff --git a/packages/backend/src/common/constants/timing.ts b/packages/backend/src/common/constants/timing.ts index 6f0d8037..d55d9a12 100644 --- a/packages/backend/src/common/constants/timing.ts +++ b/packages/backend/src/common/constants/timing.ts @@ -24,3 +24,22 @@ export const ZEN_TRANSFER_MAX_RETRIES = 3; // TTL export const NONCE_RESERVATION_TTL = 2 * 60 * 1000; // 2min export const DB_CONNECTION_TIMEOUT = 30_000; // 30s +export const RECENT_AGGREGATION_THRESHOLD = 2 * 60 * 1000; // 2min + +// Limits +export const MAX_SIGNERS_PER_TRANSACTION = 10; +export const MAX_LEADERBOARD_RANK_FOR_REWARD = 100; +export const PRICE_API_RETRY_ATTEMPTS = 3; +export const HTTP_STATUS_RATE_LIMIT = 429; + +// Gas buffers +export const GAS_BUFFER_EXECUTE = 50_000n; +export const GAS_BUFFER_ZEN_TRANSFER = 10_000n; + +// Display +export const ADDRESS_LOG_PREVIEW_LENGTH = 10; +export const ETH_DISPLAY_DECIMALS = 6; +export const WEI_PER_ETH = 1e18; + +// Analytics +export const ANALYTICS_LOG_WRITE_DELAY_MAX = 500; diff --git a/packages/backend/src/common/utils/membership.ts b/packages/backend/src/common/utils/membership.ts new file mode 100644 index 00000000..c132118a --- /dev/null +++ b/packages/backend/src/common/utils/membership.ts @@ -0,0 +1,31 @@ +import { ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '@/database/prisma.service'; +import { NOT_MEMBER_OF_ACCOUNT } from '@/common/constants'; + +/** + * Check if user is a signer of the account. Throws ForbiddenException if not. + * @param prisma - PrismaService instance + * @param accountLookup - Either { accountId } or { accountAddress } to identify the account + * @param userCommitment - User's commitment string + */ +export async function checkAccountMembership( + prisma: PrismaService, + accountLookup: { accountId: string } | { accountAddress: string }, + userCommitment: string, +): Promise { + const accountWhere = + 'accountId' in accountLookup + ? { id: accountLookup.accountId } + : { address: accountLookup.accountAddress }; + + const membership = await prisma.accountSigner.findFirst({ + where: { + account: accountWhere, + user: { commitment: userCommitment }, + }, + }); + + if (!membership) { + throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); + } +} diff --git a/packages/backend/src/config/config.keys.ts b/packages/backend/src/config/config.keys.ts index 8a338f3a..a6b78436 100644 --- a/packages/backend/src/config/config.keys.ts +++ b/packages/backend/src/config/config.keys.ts @@ -20,4 +20,8 @@ export const CONFIG_KEYS = { RELAYER_ZK_VERIFY_API_KEY: 'relayer.zkVerifyApiKey', RELAYER_WALLET_KEY: 'relayer.walletKey', REWARD_WALLET_KEY: 'relayer.rewardWalletKey', + + // Telegram + TELEGRAM_BOT_TOKEN: 'telegram.botToken', + TELEGRAM_CHAT_ID: 'telegram.chatId', } as const; diff --git a/packages/backend/src/config/config.module.ts b/packages/backend/src/config/config.module.ts index 270fdc05..a1c781e8 100644 --- a/packages/backend/src/config/config.module.ts +++ b/packages/backend/src/config/config.module.ts @@ -4,13 +4,20 @@ import databaseConfig from './database.config'; import appConfig from './app.config'; import jwtConfig from './jwt.config'; import relayerConfig from './relayer.config'; +import telegramConfig from './telegram.config'; import { validationSchema } from './env.validation'; @Module({ imports: [ NestConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, jwtConfig, relayerConfig], + load: [ + appConfig, + databaseConfig, + jwtConfig, + relayerConfig, + telegramConfig, + ], envFilePath: ['.env.local', '.env'], cache: true, expandVariables: true, diff --git a/packages/backend/src/config/env.validation.ts b/packages/backend/src/config/env.validation.ts index 71289130..ccf96eab 100644 --- a/packages/backend/src/config/env.validation.ts +++ b/packages/backend/src/config/env.validation.ts @@ -36,4 +36,8 @@ export const validationSchema = Joi.object({ 'string.empty': 'RELAYER_WALLET_KEY is required', 'any.required': 'RELAYER_WALLET_KEY is required', }), + + // Telegram alerts - optional + TELEGRAM_BOT_TOKEN: Joi.string().optional(), + TELEGRAM_CHAT_ID: Joi.string().optional(), }); diff --git a/packages/backend/src/config/telegram.config.ts b/packages/backend/src/config/telegram.config.ts new file mode 100644 index 00000000..58ee07c2 --- /dev/null +++ b/packages/backend/src/config/telegram.config.ts @@ -0,0 +1,6 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('telegram', () => ({ + botToken: process.env.TELEGRAM_BOT_TOKEN, + chatId: process.env.TELEGRAM_CHAT_ID, +})); diff --git a/packages/backend/src/contact-book/contact-book.service.ts b/packages/backend/src/contact-book/contact-book.service.ts index 361d68bd..2731689a 100644 --- a/packages/backend/src/contact-book/contact-book.service.ts +++ b/packages/backend/src/contact-book/contact-book.service.ts @@ -1,12 +1,11 @@ -import { NOT_MEMBER_OF_ACCOUNT } from '@/common/constants'; import { PrismaService } from '@/database/prisma.service'; +import { checkAccountMembership } from '@/common/utils/membership'; import { Prisma } from '@/generated/prisma/client'; import { Injectable, NotFoundException, BadRequestException, ConflictException, - ForbiddenException, Logger, } from '@nestjs/common'; import { @@ -45,17 +44,11 @@ export class ContactBookService { // ============ CONTACT GROUP ============ async createGroup(dto: CreateContactGroupDto, userCommitment: string) { - // Check if user is a signer of the account - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { id: dto.accountId }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership( + this.prisma, + { accountId: dto.accountId }, + userCommitment, + ); if (dto.contactIds?.length) { const contacts = await this.prisma.contact.findMany({ @@ -88,17 +81,7 @@ export class ContactBookService { } async getGroups(accountId: string, userCommitment: string) { - // Check if user is a signer of the account - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { id: accountId }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership(this.prisma, { accountId }, userCommitment); return this.prisma.contactGroup.findMany({ where: { accountId }, @@ -175,17 +158,11 @@ export class ContactBookService { // ============ CONTACT ============ async createContact(dto: CreateContactDto, userCommitment: string) { - // Check if user is a signer of the account - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { id: dto.accountId }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership( + this.prisma, + { accountId: dto.accountId }, + userCommitment, + ); const groups = await this.prisma.contactGroup.findMany({ where: { id: { in: dto.groupIds }, accountId: dto.accountId }, @@ -221,18 +198,8 @@ export class ContactBookService { userCommitment?: string, groupId?: string, ) { - // Check if user is a signer of the account if (userCommitment) { - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { id: accountId }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership(this.prisma, { accountId }, userCommitment); } return this.prisma.contact.findMany({ diff --git a/packages/backend/src/database/prisma.service.ts b/packages/backend/src/database/prisma.service.ts index 06f469fc..fe88ddc1 100644 --- a/packages/backend/src/database/prisma.service.ts +++ b/packages/backend/src/database/prisma.service.ts @@ -8,7 +8,7 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { constructor() { - const connectionString = process.env.DATABASE_URL!; + const connectionString = process.env.DATABASE_URL; const adapter = new PrismaPg({ connectionString }); super({ adapter }); } diff --git a/packages/backend/src/notification/notification.service.ts b/packages/backend/src/notification/notification.service.ts index eae2c4de..6176534e 100644 --- a/packages/backend/src/notification/notification.service.ts +++ b/packages/backend/src/notification/notification.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, } from '@nestjs/common'; import { PrismaService } from '@/database/prisma.service'; +import { ADDRESS_LOG_PREVIEW_LENGTH } from '@/common/constants/timing'; import { SendCommitmentDto, NotificationType, @@ -60,7 +61,7 @@ export class NotificationService { }); this.logger.log( - `Commitment sent from ${senderCommitment.slice(0, 10)}... to ${recipientCommitment.slice(0, 10)}...`, + `Commitment sent from ${senderCommitment.slice(0, ADDRESS_LOG_PREVIEW_LENGTH)}... to ${recipientCommitment.slice(0, ADDRESS_LOG_PREVIEW_LENGTH)}...`, ); // Emit realtime event to recipient diff --git a/packages/backend/src/price/price.module.ts b/packages/backend/src/price/price.module.ts index e7a9df98..38359745 100644 --- a/packages/backend/src/price/price.module.ts +++ b/packages/backend/src/price/price.module.ts @@ -5,7 +5,12 @@ import axiosRetry from 'axios-retry'; import { PriceController } from './price.controller'; import { PriceService } from './price.service'; import { PriceScheduler } from './price.scheduler'; -import { HTTP_TIMEOUT_PRICE, PRICE_CACHE_TTL } from '@/common/constants/timing'; +import { + HTTP_TIMEOUT_PRICE, + PRICE_CACHE_TTL, + PRICE_API_RETRY_ATTEMPTS, + HTTP_STATUS_RATE_LIMIT, +} from '@/common/constants/timing'; @Module({ imports: [ @@ -24,12 +29,12 @@ export class PriceModule { constructor(private httpService: HttpService) { // Configure axios-retry axiosRetry(this.httpService.axiosRef, { - retries: 3, + retries: PRICE_API_RETRY_ATTEMPTS, retryDelay: axiosRetry.exponentialDelay, retryCondition: (error) => { return ( axiosRetry.isNetworkOrIdempotentRequestError(error) || - error.response?.status === 429 + error.response?.status === HTTP_STATUS_RATE_LIMIT ); }, onRetry: (retryCount, error) => { diff --git a/packages/backend/src/price/price.service.ts b/packages/backend/src/price/price.service.ts index f72c60ce..8464d028 100644 --- a/packages/backend/src/price/price.service.ts +++ b/packages/backend/src/price/price.service.ts @@ -4,7 +4,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { firstValueFrom } from 'rxjs'; import { getCoingeckoIds } from '@polypay/shared'; -import { ZEN_COINGECKO_ID } from '@/common/constants'; +import { ZEN_COINGECKO_ID, COINGECKO_API_URL } from '@/common/constants'; import { PRICE_CACHE_TTL, PRICE_CAPTURE_MAX_RETRIES, @@ -21,8 +21,7 @@ export class PriceService { private readonly logger = new Logger(PriceService.name); private readonly CACHE_KEY = 'token-prices'; private readonly CACHE_TTL = PRICE_CACHE_TTL; - private readonly COINGECKO_API = - 'https://api.coingecko.com/api/v3/simple/price'; + private readonly COINGECKO_API = COINGECKO_API_URL; constructor( private httpService: HttpService, diff --git a/packages/backend/src/quest/quest-seeder.service.ts b/packages/backend/src/quest/quest-seeder.service.ts index d3c87ac8..32405a47 100644 --- a/packages/backend/src/quest/quest-seeder.service.ts +++ b/packages/backend/src/quest/quest-seeder.service.ts @@ -1,22 +1,24 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '@/database/prisma.service'; import { QuestCategory, QuestCode } from '@polypay/shared'; +import { + QUEST_POINTS_ACCOUNT_FIRST_TX, + QUEST_POINTS_SUCCESSFUL_TX, +} from '@/common/constants/campaign'; const QUEST_SEED_DATA = [ { code: QuestCode.ACCOUNT_FIRST_TX, name: 'Account creation', - description: - 'Each account created and performed 1 successful transaction will receive 100 points link to the wallet addresses use for signin', - points: 100, + description: `Each account created and performed 1 successful transaction will receive ${QUEST_POINTS_ACCOUNT_FIRST_TX} points link to the wallet addresses use for signin`, + points: QUEST_POINTS_ACCOUNT_FIRST_TX, type: QuestCategory.RECURRING, }, { code: QuestCode.SUCCESSFUL_TX, name: 'Transaction', - description: - 'Each successful transaction by an account earns 50 points, credited to the address that initiates the transaction.', - points: 50, + description: `Each successful transaction by an account earns ${QUEST_POINTS_SUCCESSFUL_TX} points, credited to the address that initiates the transaction.`, + points: QUEST_POINTS_SUCCESSFUL_TX, type: QuestCategory.RECURRING, }, ]; diff --git a/packages/backend/src/relayer-wallet/relayer-wallet.service.ts b/packages/backend/src/relayer-wallet/relayer-wallet.service.ts index 16cc8b14..ebc7a363 100644 --- a/packages/backend/src/relayer-wallet/relayer-wallet.service.ts +++ b/packages/backend/src/relayer-wallet/relayer-wallet.service.ts @@ -15,6 +15,8 @@ import { METAMULTISIG_ABI, METAMULTISIG_BYTECODE } from '@polypay/shared'; import { ConfigService } from '@nestjs/config'; import { CONFIG_KEYS } from '@/config/config.keys'; import { waitForReceiptWithRetry } from '@/common/utils/retry'; +import { SUPPORTED_CHAIN_IDS } from '@/common/constants/campaign'; +import { GAS_BUFFER_EXECUTE } from '@/common/constants/timing'; type RelayerChainClient = { chain: any; @@ -45,7 +47,7 @@ export class RelayerService { this.account = privateKeyToAccount(privateKey); // Initialize clients for all supported chains - const supportedChainIds = [2651420, 84532, 26514, 8453]; + const supportedChainIds = SUPPORTED_CHAIN_IDS; for (const chainId of supportedChainIds) { const chain = getChainById(chainId); @@ -151,6 +153,7 @@ export class RelayerService { leafCount: number; index: number; }[], + onTxSubmitted?: (txHash: string) => Promise, ): Promise<{ txHash: string }> { const { publicClient, walletClient, chain } = this.getChainClient(chainId); @@ -336,7 +339,7 @@ export class RelayerService { this.logger.log(`Gas estimate for execute: ${gasEstimate}`); - // 4. Execute + // 4. Submit transaction to chain const txHash = await walletClient.writeContract({ address: accountAddress as `0x${string}`, abi: METAMULTISIG_ABI, @@ -344,12 +347,18 @@ export class RelayerService { args, account: this.account, chain, - gas: gasEstimate + 50000n, + gas: gasEstimate + GAS_BUFFER_EXECUTE, }); this.logger.log(`Execute tx sent: ${txHash}`); - // 5. Wait for receipt and verify status + // 5. Notify caller of txHash before waiting for receipt + // (caller should persist txHash to DB at this point) + if (onTxSubmitted) { + await onTxSubmitted(txHash); + } + + // 6. Wait for receipt and verify status const receipt = await waitForReceiptWithRetry(publicClient, txHash); if (receipt.status === 'reverted') { diff --git a/packages/backend/src/reward/reward.service.ts b/packages/backend/src/reward/reward.service.ts index 91677325..39b2fcd2 100644 --- a/packages/backend/src/reward/reward.service.ts +++ b/packages/backend/src/reward/reward.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '@/database/prisma.service'; import { PriceService } from '@/price/price.service'; +import { MAX_LEADERBOARD_RANK_FOR_REWARD } from '@/common/constants/timing'; import { ClaimableWeek, ClaimSummary, @@ -140,8 +141,7 @@ export class RewardService { // Get user rank for this week const rank = await this.getUserRankForWeek(commitment, week); - // Skip if no rank or rank > 100 - if (!rank || rank > 100) continue; + if (!rank || rank > MAX_LEADERBOARD_RANK_FOR_REWARD) continue; const expired = isClaimExpired(week); const claimDeadline = getClaimDeadline(week).toISOString(); diff --git a/packages/backend/src/reward/zen-transfer.service.ts b/packages/backend/src/reward/zen-transfer.service.ts index 50f89427..fc536197 100644 --- a/packages/backend/src/reward/zen-transfer.service.ts +++ b/packages/backend/src/reward/zen-transfer.service.ts @@ -16,6 +16,7 @@ import { ZEN_TOKEN_ADDRESS, ZEN_DECIMALS } from '@/common/constants'; import { ZEN_TRANSFER_MAX_RETRIES, ZEN_TRANSFER_RETRY_DELAY, + GAS_BUFFER_ZEN_TRANSFER, } from '@/common/constants/timing'; const ERC20_ABI = [ @@ -37,9 +38,6 @@ const ERC20_ABI = [ }, ] as const; -const MAX_SEND_RETRIES = ZEN_TRANSFER_MAX_RETRIES; -const RETRY_DELAY_MS = ZEN_TRANSFER_RETRY_DELAY; - @Injectable() export class ZenTransferService { private readonly logger = new Logger(ZenTransferService.name); @@ -148,7 +146,7 @@ export class ZenTransferService { // Retry loop for sending transaction let lastError: Error | undefined; - for (let attempt = 1; attempt <= MAX_SEND_RETRIES; attempt++) { + for (let attempt = 1; attempt <= ZEN_TRANSFER_MAX_RETRIES; attempt++) { try { // Estimate gas const gasEstimate = await this.publicClient.estimateContractGas({ @@ -169,7 +167,7 @@ export class ZenTransferService { args: [toAddress as `0x${string}`, amountWei], account: this.account, chain: this.chain, - gas: gasEstimate + 10000n, // Add buffer + gas: gasEstimate + GAS_BUFFER_ZEN_TRANSFER, }); this.logger.log(`Transaction sent: ${txHash} (attempt ${attempt})`); @@ -196,15 +194,15 @@ export class ZenTransferService { `sendZen attempt ${attempt} failed: ${error.message}`, ); - if (attempt < MAX_SEND_RETRIES) { - this.logger.warn(`Retrying in ${RETRY_DELAY_MS}ms...`); - await sleep(RETRY_DELAY_MS); + if (attempt < ZEN_TRANSFER_MAX_RETRIES) { + this.logger.warn(`Retrying in ${ZEN_TRANSFER_RETRY_DELAY}ms...`); + await sleep(ZEN_TRANSFER_RETRY_DELAY); } } } throw new Error( - `Failed to send ZEN after ${MAX_SEND_RETRIES} attempts: ${lastError?.message}`, + `Failed to send ZEN after ${ZEN_TRANSFER_MAX_RETRIES} attempts: ${lastError?.message}`, ); } } diff --git a/packages/backend/src/transaction/transaction-executor.service.ts b/packages/backend/src/transaction/transaction-executor.service.ts new file mode 100644 index 00000000..e4c28e12 --- /dev/null +++ b/packages/backend/src/transaction/transaction-executor.service.ts @@ -0,0 +1,649 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '@/database/prisma.service'; +import { ZkVerifyService } from '@/zkverify/zkverify.service'; +import { + TxType, + encodeUpdateThreshold, + encodeBatchTransferMulti, + encodeERC20Transfer, + encodeBatchTransfer, + TxStatus, + TX_STATUS_EVENT, + VoteType, + ProofStatus, + TxStatusEventData, + encodeAddSigners, + encodeRemoveSigners, + SignerData, + ZERO_ADDRESS, +} from '@polypay/shared'; +import { RelayerService } from '@/relayer-wallet/relayer-wallet.service'; +import { + CROSS_CHAIN_FINALIZATION_WAIT, + PROOF_AGGREGATION_INTERVAL, + PROOF_AGGREGATION_MAX_ATTEMPTS, + RECENT_AGGREGATION_THRESHOLD, + ETH_DISPLAY_DECIMALS, + WEI_PER_ETH, +} from '@/common/constants/timing'; +import { EventsService } from '@/events/events.service'; +import { Transaction } from '@/generated/prisma/client'; +import { AnalyticsLoggerService } from '@/common/analytics-logger.service'; +import { QuestService } from '@/quest/quest.service'; + +@Injectable() +export class TransactionExecutorService { + private readonly logger = new Logger(TransactionExecutorService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly zkVerifyService: ZkVerifyService, + private readonly relayerService: RelayerService, + private readonly eventsService: EventsService, + private readonly analyticsLogger: AnalyticsLoggerService, + private readonly questService: QuestService, + ) {} + + /** + * Execute transaction on-chain via relayer + */ + async executeOnChain(txId: number, userAddress?: string) { + const transaction = await this.prisma.transaction.findUnique({ + where: { txId }, + include: { account: true }, + }); + + if (!transaction) { + throw new NotFoundException(`Transaction ${txId} not found`); + } + + // Mark as EXECUTING + await this.updateStatusAndEmit( + txId, + transaction.accountAddress, + TxStatus.EXECUTING, + ); + + // 1. Get execution data + const executionData = await this.getExecutionData(txId, transaction); + + // Track whether tx was submitted on-chain (txHash saved to DB) + let submittedTxHash: string | null = null; + + try { + // 2. Execute via relayer + const { txHash } = await this.relayerService.executeTransaction( + executionData.accountAddress, + executionData.nonce, + executionData.to, + executionData.value, + executionData.data, + transaction.account.chainId, + executionData.zkProofs, + // Save txHash to DB immediately after on-chain submission, + // before waiting for receipt. This prevents stuck EXECUTING state + // if receipt polling or markExecuted fails. + async (txHash: string) => { + submittedTxHash = txHash; + await this.prisma.transaction.update({ + where: { txId }, + data: { txHash }, + }); + this.logger.log( + `Persisted txHash ${txHash} for txId ${txId} before receipt confirmation`, + ); + }, + ); + + // 3. Mark as executed only on success + const { pointsAwarded } = await this.markExecuted(txId, txHash); + + this.analyticsLogger.logExecute( + userAddress, + transaction.accountAddress, + txHash, + ); + + return { txId, txHash, status: TxStatus.EXECUTED, pointsAwarded }; + } catch (error) { + this.logger.error(`Execute failed for txId ${txId}: ${error.message}`); + + if (submittedTxHash) { + // Tx was submitted on-chain — need to distinguish revert vs unknown failure + const isOnChainRevert = + error.message?.includes('reverted') || + error.message?.includes('Transaction reverted'); + + if (isOnChainRevert) { + // Tx confirmed as REVERTED on-chain — safe to revert to PENDING + this.logger.warn( + `txId ${txId} reverted on-chain (txHash: ${submittedTxHash}). Reverting to PENDING.`, + ); + await this.updateStatusAndEmit( + txId, + executionData.accountAddress, + TxStatus.PENDING, + ); + throw new BadRequestException( + 'Transaction reverted on-chain. Please check contract conditions.', + ); + } + + // Receipt timeout, network error, or markExecuted DB failure. + // Tx may have succeeded on-chain — keep EXECUTING + txHash for reconciliation. + this.logger.warn( + `txId ${txId} has on-chain txHash ${submittedTxHash} but post-submission failed: ${error.message}. Status kept as EXECUTING for reconciliation.`, + ); + throw new BadRequestException( + 'Transaction was submitted on-chain but confirmation failed. Please check transaction status.', + ); + } + + // Tx was NOT submitted on-chain — safe to revert to PENDING + await this.updateStatusAndEmit( + txId, + executionData.accountAddress, + TxStatus.PENDING, + ); + + if (error.message?.includes('Insufficient wallet balance')) { + const match = error.message.match( + /Required: (\d+) wei, Available: (\d+) wei/, + ); + if (match) { + const required = BigInt(match[1]); + const available = BigInt(match[2]); + const requiredEth = Number(required) / WEI_PER_ETH; + const availableEth = Number(available) / WEI_PER_ETH; + throw new BadRequestException( + `Insufficient account balance. Required: ${requiredEth.toFixed(ETH_DISPLAY_DECIMALS)} ETH, Available: ${availableEth.toFixed(ETH_DISPLAY_DECIMALS)} ETH`, + ); + } + } + + throw new BadRequestException( + error.message || 'Failed to execute transaction', + ); + } + } + + /** + * Get execution data for smart contract + */ + async getExecutionData(txId: number, transaction: Transaction) { + // Aggregate all pending proofs (poll until all aggregated) + await this.aggregateProofs(txId); + + // Get aggregated approve votes + const approveVotes = await this.prisma.vote.findMany({ + where: { + txId, + voteType: VoteType.APPROVE, + proofStatus: ProofStatus.AGGREGATED, + }, + }); + + if (approveVotes.length < transaction.threshold) { + throw new BadRequestException( + `Not enough aggregated proofs. Required: ${transaction.threshold}, Got: ${approveVotes.length}`, + ); + } + + // Build execute params based on tx type + const { to, value, data } = this.buildExecuteParams(transaction); + + // Format proofs for smart contract + const zkProofs = approveVotes.map((vote) => ({ + commitment: vote.voterCommitment, + nullifier: vote.nullifier, + aggregationId: vote.aggregationId, + domainId: vote.domainId, + zkMerklePath: vote.merkleProof, + leafCount: vote.leafCount, + index: vote.leafIndex, + })); + + return { + txId, + nonce: transaction.nonce, + accountAddress: transaction.accountAddress, + to, + value, + data, + zkProofs, + threshold: transaction.threshold, + }; + } + + /** + * Mark transaction as executed + */ + async markExecuted(txId: number, txHash: string) { + return this.prisma.$transaction(async (tx) => { + const transaction = await tx.transaction.findUnique({ + where: { txId }, + }); + + if (!transaction) { + throw new NotFoundException(`Transaction ${txId} not found`); + } + + // Handle signer/threshold changes + if (transaction.type === TxType.ADD_SIGNER && transaction.signerData) { + await this.handleAddSignerExecution(tx, transaction, txId); + } + + if (transaction.type === TxType.REMOVE_SIGNER && transaction.signerData) { + await this.handleRemoveSignerExecution(tx, transaction, txId); + } + + if ( + transaction.type === TxType.SET_THRESHOLD && + transaction.newThreshold + ) { + await this.updatePendingTransactionThreshold( + tx, + transaction.accountAddress, + transaction.newThreshold, + txId, + ); + } + + // Mark this transaction as EXECUTED + const updatedTx = tx.transaction.update({ + where: { txId }, + data: { + status: TxStatus.EXECUTED, + txHash, + executedAt: new Date(), + }, + }); + + // Award quest points (only for TRANSFER and BATCH transactions) + let pointsAwarded = 0; + if ( + transaction.type === TxType.TRANSFER || + transaction.type === TxType.BATCH + ) { + try { + const successfulTxPoints = await this.questService.awardSuccessfulTx( + txId, + transaction.createdBy, + ); + const firstTxPoints = await this.questService.awardAccountFirstTx( + transaction.accountAddress, + txId, + ); + pointsAwarded = successfulTxPoints + firstTxPoints; + } catch (error) { + this.logger.error(`Failed to award quest points: ${error.message}`); + } + } + + // Emit event for status update + const eventData: TxStatusEventData = { + txId, + status: TxStatus.EXECUTED, + txHash, + }; + this.eventsService.emitToAccount( + transaction.accountAddress, + TX_STATUS_EVENT, + eventData, + ); + + const result = await updatedTx; + return { ...result, pointsAwarded }; + }); + } + + // ============ Private Methods ============ + + private async handleAddSignerExecution( + tx: any, + transaction: Transaction, + txId: number, + ) { + const signers: SignerData[] = JSON.parse(transaction.signerData); + + const account = await tx.account.findUnique({ + where: { address: transaction.accountAddress }, + }); + + if (!account) return; + + for (const signer of signers) { + const user = await tx.user.upsert({ + where: { commitment: signer.commitment }, + create: { commitment: signer.commitment }, + update: {}, + }); + + await tx.accountSigner.upsert({ + where: { + userId_accountId: { + userId: user.id, + accountId: account.id, + }, + }, + create: { + userId: user.id, + accountId: account.id, + isCreator: false, + displayName: signer.name || null, + }, + update: { + displayName: signer.name || undefined, + }, + }); + + this.logger.log( + `Added signer ${signer.commitment} (${signer.name || 'no name'}) to account ${account.address}`, + ); + } + + if (transaction.newThreshold) { + await this.updatePendingTransactionThreshold( + tx, + transaction.accountAddress, + transaction.newThreshold, + txId, + ); + } + } + + private async handleRemoveSignerExecution( + tx: any, + transaction: Transaction, + txId: number, + ) { + const signers: SignerData[] = JSON.parse(transaction.signerData); + + const account = await tx.account.findUnique({ + where: { address: transaction.accountAddress }, + }); + + if (!account) return; + + for (const signer of signers) { + const user = await tx.user.findUnique({ + where: { commitment: signer.commitment }, + }); + + if (user) { + await tx.accountSigner.deleteMany({ + where: { + userId: user.id, + accountId: account.id, + }, + }); + + this.logger.log( + `Removed signer ${signer.commitment} from account ${account.address}`, + ); + } + + // Delete all pending votes from removed signer (same account only) + const deletedVotes = await tx.vote.deleteMany({ + where: { + voterCommitment: signer.commitment, + transaction: { + accountAddress: transaction.accountAddress, + status: { in: [TxStatus.PENDING] }, + }, + }, + }); + + if (deletedVotes.count > 0) { + this.logger.log( + `Deleted ${deletedVotes.count} pending votes from removed signer ${signer.commitment}`, + ); + } + } + + if (transaction.newThreshold) { + await this.updatePendingTransactionThreshold( + tx, + transaction.accountAddress, + transaction.newThreshold, + txId, + ); + } + } + + private async updatePendingTransactionThreshold( + tx: any, + accountAddress: string, + newThreshold: number, + currentTxId: number, + ) { + const updatedTxs = await tx.transaction.updateMany({ + where: { + accountAddress, + status: TxStatus.PENDING, + txId: { not: currentTxId }, + }, + data: { + threshold: newThreshold, + }, + }); + + if (updatedTxs.count > 0) { + this.logger.log( + `Updated threshold to ${newThreshold} for ${updatedTxs.count} pending transactions in account ${accountAddress}`, + ); + } + } + + private buildExecuteParams(transaction: Transaction): { + to: string; + value: string; + data: string; + } { + switch (transaction.type) { + case TxType.TRANSFER: + if (transaction?.tokenAddress) { + return { + to: transaction.tokenAddress, + value: '0', + data: encodeERC20Transfer( + transaction.to, + BigInt(transaction.value), + ), + }; + } + return { + to: transaction.to, + value: transaction.value, + data: '0x', + }; + + case TxType.ADD_SIGNER: { + const signers: SignerData[] = transaction.signerData + ? JSON.parse(transaction.signerData) + : []; + return { + to: transaction.accountAddress, + value: '0', + data: encodeAddSigners( + signers.map((s) => s.commitment), + transaction.newThreshold, + ), + }; + } + + case TxType.REMOVE_SIGNER: { + const signers: SignerData[] = transaction.signerData + ? JSON.parse(transaction.signerData) + : []; + return { + to: transaction.accountAddress, + value: '0', + data: encodeRemoveSigners( + signers.map((s) => s.commitment), + transaction.newThreshold, + ), + }; + } + + case TxType.SET_THRESHOLD: + return { + to: transaction.accountAddress, + value: '0', + data: encodeUpdateThreshold(transaction.newThreshold), + }; + + case TxType.BATCH: + const batchData = JSON.parse(transaction.batchData || '[]'); + const recipients = batchData.map((item: any) => item.recipient); + const amounts = batchData.map((item: any) => BigInt(item.amount)); + const tokenAddresses = batchData.map( + (item: any) => item.tokenAddress || ZERO_ADDRESS, + ); + + const hasERC20 = tokenAddresses.some( + (addr: string) => addr !== ZERO_ADDRESS, + ); + + if (hasERC20) { + return { + to: transaction.accountAddress, + value: '0', + data: encodeBatchTransferMulti(recipients, amounts, tokenAddresses), + }; + } + + return { + to: transaction.accountAddress, + value: '0', + data: encodeBatchTransfer(recipients, amounts), + }; + + default: + throw new BadRequestException(`Unknown transaction type`); + } + } + + private async aggregateProofs( + txId: number, + maxAttempts = PROOF_AGGREGATION_MAX_ATTEMPTS, + intervalMs = PROOF_AGGREGATION_INTERVAL, + ) { + let hasRecentAggregation = false; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const pendingVotes = await this.prisma.vote.findMany({ + where: { + txId, + voteType: VoteType.APPROVE, + proofStatus: ProofStatus.PENDING, + }, + }); + + if (pendingVotes.length === 0) { + this.logger.log(`All proofs aggregated for txId: ${txId}`); + break; + } + + this.logger.log( + `Attempt ${attempt + 1}/${maxAttempts}: ${pendingVotes.length} pending proofs for txId: ${txId}`, + ); + + for (const vote of pendingVotes) { + if (!vote.jobId) continue; + + try { + const jobStatus = await this.zkVerifyService.getJobStatus(vote.jobId); + + if (jobStatus.status === 'Aggregated') { + this.logger.log(`job data: ${JSON.stringify(jobStatus)}`); + + const updatedAt = new Date(jobStatus.updatedAt).getTime(); + const now = Date.now(); + if (now - updatedAt < RECENT_AGGREGATION_THRESHOLD) { + hasRecentAggregation = true; + } + + await this.prisma.vote.update({ + where: { id: vote.id }, + data: { + proofStatus: ProofStatus.AGGREGATED, + aggregationId: jobStatus.aggregationId?.toString(), + merkleProof: jobStatus.aggregationDetails?.merkleProof || [], + leafCount: jobStatus.aggregationDetails?.numberOfLeaves, + leafIndex: jobStatus.aggregationDetails?.leafIndex, + }, + }); + + this.logger.log(`Vote ${vote.id} aggregated successfully`); + } else if (jobStatus.status === 'Failed') { + await this.prisma.vote.update({ + where: { id: vote.id }, + data: { proofStatus: ProofStatus.FAILED }, + }); + + this.logger.error(`Vote ${vote.id} proof failed`); + } + } catch (error) { + this.logger.error(`Error checking vote ${vote.id}:`, error); + } + } + + await this.sleep(intervalMs); + } + + const stillPending = await this.prisma.vote.count({ + where: { + txId, + voteType: VoteType.APPROVE, + proofStatus: ProofStatus.PENDING, + }, + }); + + if (stillPending > 0) { + this.logger.warn( + `Timeout: ${stillPending} proofs still pending for txId: ${txId}`, + ); + throw new BadRequestException( + `Timeout waiting for proof aggregation. ${stillPending} proofs still pending.`, + ); + } + + if (hasRecentAggregation) { + this.logger.log( + 'Recent aggregation detected, waiting 40s for cross-chain finalization...', + ); + await this.sleep(CROSS_CHAIN_FINALIZATION_WAIT); + } else { + this.logger.log('All aggregations are old (> 2 minutes), skipping wait'); + } + } + + private async updateStatusAndEmit( + txId: number, + accountAddress: string, + status: TxStatus, + txHash?: string, + ) { + await this.prisma.transaction.update({ + where: { txId }, + data: { status }, + }); + + const eventData: TxStatusEventData = { txId, status, txHash }; + this.eventsService.emitToAccount( + accountAddress, + TX_STATUS_EVENT, + eventData, + ); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/backend/src/transaction/transaction.controller.ts b/packages/backend/src/transaction/transaction.controller.ts index 6f71b210..8d9c8b92 100644 --- a/packages/backend/src/transaction/transaction.controller.ts +++ b/packages/backend/src/transaction/transaction.controller.ts @@ -17,6 +17,7 @@ import { ApiQuery, ApiBearerAuth, } from '@nestjs/swagger'; +import { ThrottlerGuard, Throttle } from '@nestjs/throttler'; import { TransactionService } from './transaction.service'; import { CreateTransactionDto, @@ -25,6 +26,7 @@ import { ExecuteTransactionDto, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, + TxStatus, } from '@polypay/shared'; import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard'; import { CurrentUser } from '@/auth/decorators/current-user.decorator'; @@ -33,6 +35,7 @@ import { User } from '@/generated/prisma/client'; @ApiTags('transactions') @Controller('transactions') +@UseGuards(ThrottlerGuard) export class TransactionController { constructor(private readonly transactionService: TransactionService) {} @@ -42,6 +45,7 @@ export class TransactionController { */ @Post() @UseGuards(JwtAuthGuard) + @Throttle({ default: { ttl: 60_000, limit: 10 } }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create a new transaction with auto-approval', @@ -100,7 +104,7 @@ export class TransactionController { name: 'status', required: false, description: 'Transaction status filter', - enum: ['PENDING', 'EXECUTED', 'FAILED'], + enum: [TxStatus.PENDING, TxStatus.EXECUTED, TxStatus.FAILED], }) @ApiQuery({ name: 'limit', @@ -173,6 +177,7 @@ export class TransactionController { */ @Post(':txId/approve') @UseGuards(JwtAuthGuard, TransactionAccessGuard) + @Throttle({ default: { ttl: 60_000, limit: 10 } }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Approve a transaction with ZK proof', @@ -222,6 +227,7 @@ export class TransactionController { */ @Post(':txId/deny') @UseGuards(JwtAuthGuard, TransactionAccessGuard) + @Throttle({ default: { ttl: 60_000, limit: 20 } }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Deny/Reject a transaction', @@ -255,6 +261,7 @@ export class TransactionController { */ @Post(':txId/execute') @UseGuards(JwtAuthGuard, TransactionAccessGuard) + @Throttle({ default: { ttl: 60_000, limit: 5 } }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Execute approved transaction on-chain', @@ -294,6 +301,7 @@ export class TransactionController { */ @Post('reserve-nonce') @UseGuards(JwtAuthGuard) + @Throttle({ default: { ttl: 60_000, limit: 10 } }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Reserve a nonce for new transaction', diff --git a/packages/backend/src/transaction/transaction.module.ts b/packages/backend/src/transaction/transaction.module.ts index 7cae47b4..0ad3faef 100644 --- a/packages/backend/src/transaction/transaction.module.ts +++ b/packages/backend/src/transaction/transaction.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TransactionController } from './transaction.controller'; import { TransactionService } from './transaction.service'; +import { TransactionExecutorService } from './transaction-executor.service'; import { ZkVerifyModule } from '@/zkverify/zkverify.module'; import { DatabaseModule } from '@/database/database.module'; import { RelayerModule } from '@/relayer-wallet/relayer-wallet.module'; @@ -19,7 +20,11 @@ import { QuestModule } from '@/quest/quest.module'; QuestModule, ], controllers: [TransactionController], - providers: [TransactionService, AnalyticsLoggerService], + providers: [ + TransactionService, + TransactionExecutorService, + AnalyticsLoggerService, + ], exports: [TransactionService], }) export class TransactionModule {} diff --git a/packages/backend/src/transaction/transaction.service.ts b/packages/backend/src/transaction/transaction.service.ts index 7029fdfc..e4695ba2 100644 --- a/packages/backend/src/transaction/transaction.service.ts +++ b/packages/backend/src/transaction/transaction.service.ts @@ -3,7 +3,6 @@ import { Logger, BadRequestException, NotFoundException, - ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '@/database/prisma.service'; import { ZkVerifyService } from '@/zkverify/zkverify.service'; @@ -11,10 +10,6 @@ import { CreateTransactionDto, ApproveTransactionDto, TxType, - encodeUpdateThreshold, - encodeBatchTransferMulti, - encodeERC20Transfer, - encodeBatchTransfer, TxStatus, TX_CREATED_EVENT, TX_VOTED_EVENT, @@ -26,25 +21,17 @@ import { TxStatusEventData, PaginatedResponse, DEFAULT_PAGE_SIZE, - encodeAddSigners, - encodeRemoveSigners, - SignerData, - ZERO_ADDRESS, } from '@polypay/shared'; -import { RelayerService } from '@/relayer-wallet/relayer-wallet.service'; import { BatchItemService } from '@/batch-item/batch-item.service'; -import { NOT_MEMBER_OF_ACCOUNT } from '@/common/constants'; import { - CROSS_CHAIN_FINALIZATION_WAIT, + MAX_SIGNERS_PER_TRANSACTION, NONCE_RESERVATION_TTL, - PROOF_AGGREGATION_INTERVAL, - PROOF_AGGREGATION_MAX_ATTEMPTS, } from '@/common/constants/timing'; +import { checkAccountMembership } from '@/common/utils/membership'; import { EventsService } from '@/events/events.service'; -import { Transaction } from '@/generated/prisma/client'; import { AnalyticsLoggerService } from '@/common/analytics-logger.service'; import { getDomainId } from '@/common/utils/proof'; -import { QuestService } from '@/quest/quest.service'; +import { TransactionExecutorService } from './transaction-executor.service'; @Injectable() export class TransactionService { @@ -53,28 +40,21 @@ export class TransactionService { constructor( private prisma: PrismaService, private zkVerifyService: ZkVerifyService, - private relayerService: RelayerService, private batchItemService: BatchItemService, private readonly eventsService: EventsService, private readonly analyticsLogger: AnalyticsLoggerService, - private readonly questService: QuestService, + private readonly transactionExecutor: TransactionExecutorService, ) {} /** * Create transaction with txId from smart contract nonce */ async createTransaction(dto: CreateTransactionDto, userCommitment: string) { - // Check if user is a signer of the account - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { address: dto.accountAddress }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership( + this.prisma, + { accountAddress: dto.accountAddress }, + userCommitment, + ); // 1. Validate based on type this.validateTransactionDto(dto); @@ -136,7 +116,7 @@ export class TransactionService { ); } - // 4. Submit proof to zkVerify + // 4. Submit proof to zkVerify (throws on failure) const proofResult = await this.zkVerifyService.submitProofAndWaitFinalized( { proof: dto.proof, @@ -147,10 +127,6 @@ export class TransactionService { account.chainId, ); - if (proofResult.status === 'Failed') { - throw new BadRequestException('Proof verification failed'); - } - // 5. Create transaction + first vote + delete reservation const transaction = await this.prisma.$transaction(async (prisma) => { // Delete reservation @@ -171,7 +147,7 @@ export class TransactionService { signerData: dto.signers ? JSON.stringify(dto.signers) : null, newThreshold: dto.newThreshold, createdBy: userCommitment, - status: 'PENDING', + status: TxStatus.PENDING, batchData, }, }); @@ -180,10 +156,10 @@ export class TransactionService { data: { txId: tx.txId, voterCommitment: userCommitment, - voteType: 'APPROVE', + voteType: VoteType.APPROVE, nullifier: dto.nullifier, jobId: proofResult.jobId, - proofStatus: 'PENDING', + proofStatus: ProofStatus.PENDING, domainId: getDomainId(account.chainId), zkVerifyTxHash: proofResult.txHash, }, @@ -203,44 +179,13 @@ export class TransactionService { `Created transaction txId: ${transaction.txId}, nonce: ${transaction.nonce}`, ); - this.analyticsLogger.logApprove( + this.logTransactionAnalytics( + dto.type, dto.userAddress, transaction.accountAddress, proofResult.txHash, ); - if (dto.type === TxType.ADD_SIGNER) { - this.analyticsLogger.logAddSigner( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (dto.type === TxType.REMOVE_SIGNER) { - this.analyticsLogger.logRemoveSigner( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (dto.type === TxType.SET_THRESHOLD) { - this.analyticsLogger.logUpdateThreshold( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (dto.type === TxType.TRANSFER) { - this.analyticsLogger.logTransfer( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (dto.type === TxType.BATCH) { - this.analyticsLogger.logBatchTransfer( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } - // Emit realtime event const eventData: TxCreatedEventData = { txId: transaction.txId, @@ -280,11 +225,11 @@ export class TransactionService { throw new NotFoundException(`Transaction ${txId} not found`); } - if (transaction.status === 'EXECUTED') { + if (transaction.status === TxStatus.EXECUTED) { throw new BadRequestException('Transaction already executed'); } - if (transaction.status === 'FAILED') { + if (transaction.status === TxStatus.FAILED) { throw new BadRequestException('Transaction has failed'); } @@ -314,7 +259,7 @@ export class TransactionService { throw new BadRequestException('Nullifier already used'); } - // 4. Submit proof to zkVerify + // 4. Submit proof to zkVerify (throws on failure) const proofResult = await this.zkVerifyService.submitProofAndWaitFinalized( { proof: dto.proof, @@ -325,10 +270,6 @@ export class TransactionService { transaction.account.chainId, ); - if (proofResult.status === 'Failed') { - throw new BadRequestException('Proof verification failed'); - } - const voterName = await this.getSignerDisplayName( transaction.accountAddress, userCommitment, @@ -350,44 +291,13 @@ export class TransactionService { this.logger.log(`Vote APPROVE added for txId: ${txId}`); - this.analyticsLogger.logApprove( + this.logTransactionAnalytics( + transaction.type as TxType, dto.userAddress, transaction.accountAddress, proofResult.txHash, ); - if (transaction.type === TxType.ADD_SIGNER) { - this.analyticsLogger.logAddSigner( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (transaction.type === TxType.REMOVE_SIGNER) { - this.analyticsLogger.logRemoveSigner( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (transaction.type === TxType.SET_THRESHOLD) { - this.analyticsLogger.logUpdateThreshold( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (transaction.type === TxType.TRANSFER) { - this.analyticsLogger.logTransfer( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } else if (transaction.type === TxType.BATCH) { - this.analyticsLogger.logBatchTransfer( - dto.userAddress, - transaction.accountAddress, - proofResult.txHash, - ); - } - // Calculate approve count const approveCount = await this.prisma.vote.count({ where: { txId, voteType: VoteType.APPROVE }, @@ -435,11 +345,11 @@ export class TransactionService { throw new NotFoundException(`Transaction ${txId} not found`); } - if (transaction.status === 'EXECUTED') { + if (transaction.status === TxStatus.EXECUTED) { throw new BadRequestException('Transaction already executed'); } - if (transaction.status === 'FAILED') { + if (transaction.status === TxStatus.FAILED) { throw new BadRequestException('Transaction already failed'); } @@ -544,18 +454,12 @@ export class TransactionService { limit: number = DEFAULT_PAGE_SIZE, cursor?: string, ): Promise> { - // Check if user is a signer of the account if (userCommitment) { - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { address: accountAddress }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership( + this.prisma, + { accountAddress }, + userCommitment, + ); } const where: any = { accountAddress }; @@ -597,34 +501,7 @@ export class TransactionService { // Remove extra item if exists const rawData = hasMore ? transactions.slice(0, limit) : transactions; - // Transform data to include voterName in votes - const data = rawData.map((tx) => { - // Create map: commitment -> displayName - const signerMap = new Map( - tx.account.signers.map((s) => [s.user.commitment, s.displayName]), - ); - - // Parse signerData from JSON string to object - let parsedSignerData = null; - if (tx.signerData) { - try { - parsedSignerData = JSON.parse(tx.signerData); - } catch { - parsedSignerData = null; - } - } - - return { - ...tx, - votes: tx.votes.map((vote) => ({ - ...vote, - voterName: signerMap.get(vote.voterCommitment) || null, - })), - signerData: parsedSignerData, - // Remove account.signers from response to reduce payload - account: undefined, - }; - }); + const data = rawData.map((tx) => this.formatTransactionResponse(tx)); // Get next cursor from last item const nextCursor = @@ -637,370 +514,19 @@ export class TransactionService { }; } - /** - * Get execution data for smart contract - */ - async getExecutionData(txId: number, transaction: Transaction) { - // Aggregate all pending proofs (poll until all aggregated) - await this.aggregateProofs(txId); - - // Get aggregated approve votes - const approveVotes = await this.prisma.vote.findMany({ - where: { - txId, - voteType: VoteType.APPROVE, - proofStatus: ProofStatus.AGGREGATED, - }, - }); - - if (approveVotes.length < transaction.threshold) { - throw new BadRequestException( - `Not enough aggregated proofs. Required: ${transaction.threshold}, Got: ${approveVotes.length}`, - ); - } - - // Build execute params based on tx type - const { to, value, data } = this.buildExecuteParams(transaction); - - // Format proofs for smart contract - const zkProofs = approveVotes.map((vote) => ({ - commitment: vote.voterCommitment, - nullifier: vote.nullifier, - aggregationId: vote.aggregationId, - domainId: vote.domainId, - zkMerklePath: vote.merkleProof, - leafCount: vote.leafCount, - index: vote.leafIndex, - })); - - return { - txId, - nonce: transaction.nonce, - accountAddress: transaction.accountAddress, - to, - value, - data, - zkProofs, - threshold: transaction.threshold, - }; - } - - /** - * Mark transaction as executed - */ - async markExecuted(txId: number, txHash: string) { - return this.prisma.$transaction(async (tx) => { - // 1. Find the transaction - const transaction = await tx.transaction.findUnique({ - where: { txId }, - }); - - if (!transaction) { - throw new NotFoundException(`Transaction ${txId} not found`); - } - - // Handle ADD_SIGNER: create User + AccountSigner link - if (transaction.type === TxType.ADD_SIGNER && transaction.signerData) { - const signers: SignerData[] = JSON.parse(transaction.signerData); - - const account = await tx.account.findUnique({ - where: { address: transaction.accountAddress }, - }); - - if (account) { - // Loop through all signers to add - for (const signer of signers) { - // Upsert user for new signer - const user = await tx.user.upsert({ - where: { commitment: signer.commitment }, - create: { commitment: signer.commitment }, - update: {}, - }); - - // Create AccountSigner link with displayName - await tx.accountSigner.upsert({ - where: { - userId_accountId: { - userId: user.id, - accountId: account.id, - }, - }, - create: { - userId: user.id, - accountId: account.id, - isCreator: false, - displayName: signer.name || null, - }, - update: { - displayName: signer.name || undefined, - }, - }); - - this.logger.log( - `Added signer ${signer.commitment} (${signer.name || 'no name'}) to account ${account.address}`, - ); - } - - // Update threshold for pending transactions if newThreshold exists - if (transaction.newThreshold) { - const updatedTxs = await tx.transaction.updateMany({ - where: { - accountAddress: transaction.accountAddress, - status: TxStatus.PENDING, - txId: { not: txId }, - }, - data: { - threshold: transaction.newThreshold, - }, - }); - - if (updatedTxs.count > 0) { - this.logger.log( - `Updated threshold to ${transaction.newThreshold} for ${updatedTxs.count} pending transactions`, - ); - } - } - } - } - - // Handle REMOVE_SIGNER: delete AccountSigner link + delete pending votes - if (transaction.type === TxType.REMOVE_SIGNER && transaction.signerData) { - const signers: SignerData[] = JSON.parse(transaction.signerData); - - const account = await tx.account.findUnique({ - where: { address: transaction.accountAddress }, - }); - - if (account) { - // Loop through all signers to remove - for (const signer of signers) { - const user = await tx.user.findUnique({ - where: { commitment: signer.commitment }, - }); - - if (user) { - await tx.accountSigner.deleteMany({ - where: { - userId: user.id, - accountId: account.id, - }, - }); - - this.logger.log( - `Removed signer ${signer.commitment} from account ${account.address}`, - ); - } - - // Delete all pending votes from removed signer (same account only) - const deletedVotes = await tx.vote.deleteMany({ - where: { - voterCommitment: signer.commitment, - transaction: { - accountAddress: transaction.accountAddress, - status: { in: ['PENDING'] }, - }, - }, - }); - - if (deletedVotes.count > 0) { - this.logger.log( - `Deleted ${deletedVotes.count} pending votes from removed signer ${signer.commitment}`, - ); - } - } - } - - // Update threshold for pending transactions if newThreshold exists - if (transaction.newThreshold) { - const updatedTxs = await tx.transaction.updateMany({ - where: { - accountAddress: transaction.accountAddress, - status: TxStatus.PENDING, - txId: { not: txId }, - }, - data: { - threshold: transaction.newThreshold, - }, - }); - - if (updatedTxs.count > 0) { - this.logger.log( - `Updated threshold to ${transaction.newThreshold} for ${updatedTxs.count} pending transactions`, - ); - } - } - } - - // Handle SET_THRESHOLD: update threshold for all pending transactions in the same account - if ( - transaction.type === TxType.SET_THRESHOLD && - transaction.newThreshold - ) { - const updatedTxs = await tx.transaction.updateMany({ - where: { - accountAddress: transaction.accountAddress, - status: TxStatus.PENDING, - txId: { not: txId }, - }, - data: { - threshold: transaction.newThreshold, - }, - }); - - if (updatedTxs.count > 0) { - this.logger.log( - `Updated threshold to ${transaction.newThreshold} for ${updatedTxs.count} pending transactions in account ${transaction.accountAddress}`, - ); - } - } - - // Mark this transaction as EXECUTED - const updatedTx = tx.transaction.update({ - where: { txId }, - data: { - status: TxStatus.EXECUTED, - txHash, - executedAt: new Date(), - }, - }); - - // Award quest points (only for TRANSFER and BATCH transactions) - let pointsAwarded = 0; - if ( - transaction.type === TxType.TRANSFER || - transaction.type === TxType.BATCH - ) { - try { - const successfulTxPoints = await this.questService.awardSuccessfulTx( - txId, - transaction.createdBy, - ); - const firstTxPoints = await this.questService.awardAccountFirstTx( - transaction.accountAddress, - txId, - ); - pointsAwarded = successfulTxPoints + firstTxPoints; - } catch (error) { - this.logger.error(`Failed to award quest points: ${error.message}`); - } - } - - // Emit event for status update - const eventData: TxStatusEventData = { - txId, - status: TxStatus.EXECUTED, - txHash, - }; - this.eventsService.emitToAccount( - transaction.accountAddress, - TX_STATUS_EVENT, - eventData, - ); - - const result = await updatedTx; - return { ...result, pointsAwarded }; - }); - } - /** * Execute transaction on-chain via relayer */ async executeOnChain(txId: number, userAddress?: string) { - // Check transaction exists - const transaction = await this.prisma.transaction.findUnique({ - where: { txId }, - include: { account: true }, - }); - - if (!transaction) { - throw new NotFoundException(`Transaction ${txId} not found`); - } - - // Mark as EXECUTING - await this.updateStatusAndEmit( - txId, - transaction.accountAddress, - TxStatus.EXECUTING, - ); - - // 1. Get execution data - const executionData = await this.getExecutionData(txId, transaction); - - try { - // 2. Execute via relayer (includes balance check + receipt verification) - const { txHash } = await this.relayerService.executeTransaction( - executionData.accountAddress, - executionData.nonce, - executionData.to, - executionData.value, - executionData.data, - transaction.account.chainId, - executionData.zkProofs, - ); - - // 3. Mark as executed only on success - const { pointsAwarded } = await this.markExecuted(txId, txHash); - - this.analyticsLogger.logExecute( - userAddress, - transaction.accountAddress, - txHash, - ); - - return { txId, txHash, status: 'EXECUTED', pointsAwarded }; - } catch (error) { - this.logger.error(`Execute failed for txId ${txId}: ${error.message}`); - - // Revert to PENDING on failure - await this.updateStatusAndEmit( - txId, - executionData.accountAddress, - TxStatus.PENDING, - ); - - // Parse error message for user-friendly response - if (error.message?.includes('Insufficient wallet balance')) { - // Extract amounts from error message - const match = error.message.match( - /Required: (\d+) wei, Available: (\d+) wei/, - ); - if (match) { - const required = BigInt(match[1]); - const available = BigInt(match[2]); - // Convert to ETH for readability - const requiredEth = Number(required) / 1e18; - const availableEth = Number(available) / 1e18; - throw new BadRequestException( - `Insufficient account balance. Required: ${requiredEth.toFixed(6)} ETH, Available: ${availableEth.toFixed(6)} ETH`, - ); - } - } - - if (error.message?.includes('Transaction reverted')) { - throw new BadRequestException( - 'Transaction reverted on-chain. Please check contract conditions.', - ); - } - - // Generic error - throw new BadRequestException( - error.message || 'Failed to execute transaction', - ); - } + return this.transactionExecutor.executeOnChain(txId, userAddress); } async reserveNonce(accountAddress: string, userCommitment: string) { - // Check if user is a signer of the account - const membership = await this.prisma.accountSigner.findFirst({ - where: { - account: { address: accountAddress }, - user: { commitment: userCommitment }, - }, - }); - - if (!membership) { - throw new ForbiddenException(NOT_MEMBER_OF_ACCOUNT); - } + await checkAccountMembership( + this.prisma, + { accountAddress }, + userCommitment, + ); return this.prisma.$transaction(async (tx) => { // 1. Clean expired reservations @@ -1057,8 +583,10 @@ export class TransactionService { 'Add signer requires "signers" (array of {commitment, name?}) and "newThreshold"', ); } - if (dto.signers.length > 10) { - throw new BadRequestException('Maximum 10 signers per transaction'); + if (dto.signers.length > MAX_SIGNERS_PER_TRANSACTION) { + throw new BadRequestException( + `Maximum ${MAX_SIGNERS_PER_TRANSACTION} signers per transaction`, + ); } break; @@ -1072,8 +600,10 @@ export class TransactionService { 'Remove signer requires "signers" (array of {commitment, name?}) and "newThreshold"', ); } - if (dto.signers.length > 10) { - throw new BadRequestException('Maximum 10 signers per transaction'); + if (dto.signers.length > MAX_SIGNERS_PER_TRANSACTION) { + throw new BadRequestException( + `Maximum ${MAX_SIGNERS_PER_TRANSACTION} signers per transaction`, + ); } break; @@ -1093,119 +623,6 @@ export class TransactionService { } } - private async updateStatusAndEmit( - txId: number, - accountAddress: string, - status: TxStatus, - txHash?: string, - ) { - await this.prisma.transaction.update({ - where: { txId }, - data: { status }, - }); - - const eventData: TxStatusEventData = { txId, status, txHash }; - this.eventsService.emitToAccount( - accountAddress, - TX_STATUS_EVENT, - eventData, - ); - } - - private buildExecuteParams(transaction: Transaction): { - to: string; - value: string; - data: string; - } { - switch (transaction.type) { - case TxType.TRANSFER: - // ERC20 transfer - if (transaction?.tokenAddress) { - return { - to: transaction.tokenAddress, // Token contract address - value: '0', - data: encodeERC20Transfer( - transaction.to, - BigInt(transaction.value), - ), - }; - } - // Native ETH transfer - return { - to: transaction.to, - value: transaction.value, - data: '0x', - }; - - case TxType.ADD_SIGNER: { - const signers: SignerData[] = transaction.signerData - ? JSON.parse(transaction.signerData) - : []; - return { - to: transaction.accountAddress, - value: '0', - data: encodeAddSigners( - signers.map((s) => s.commitment), - transaction.newThreshold, - ), - }; - } - - case TxType.REMOVE_SIGNER: { - const signers: SignerData[] = transaction.signerData - ? JSON.parse(transaction.signerData) - : []; - return { - to: transaction.accountAddress, - value: '0', - data: encodeRemoveSigners( - signers.map((s) => s.commitment), - transaction.newThreshold, - ), - }; - } - - case TxType.SET_THRESHOLD: - return { - to: transaction.accountAddress, - value: '0', - data: encodeUpdateThreshold(transaction.newThreshold), - }; - - case TxType.BATCH: - const batchData = JSON.parse(transaction.batchData || '[]'); - const recipients = batchData.map((item: any) => item.recipient); - const amounts = batchData.map((item: any) => BigInt(item.amount)); - const tokenAddresses = batchData.map( - (item: any) => item.tokenAddress || ZERO_ADDRESS, - ); - - // Check if any ERC20 token in batch - const hasERC20 = tokenAddresses.some( - (addr: string) => addr !== ZERO_ADDRESS, - ); - - if (hasERC20) { - // Use batchTransferMulti for mixed transfers - return { - to: transaction.accountAddress, - value: '0', - data: encodeBatchTransferMulti(recipients, amounts, tokenAddresses), - }; - } - - // Use original batchTransfer for ETH-only - return { - to: transaction.accountAddress, - value: '0', - data: encodeBatchTransfer(recipients, amounts), - }; - - default: - throw new BadRequestException(`Unknown transaction type`); - } - } - /** * Check if transaction should be marked as FAILED * Query totalSigners realtime from account.signers @@ -1257,119 +674,77 @@ export class TransactionService { } } - private async aggregateProofs( - txId: number, - maxAttempts = PROOF_AGGREGATION_MAX_ATTEMPTS, - intervalMs = PROOF_AGGREGATION_INTERVAL, - ) { - let hasRecentAggregation = false; - const TWO_MINUTES_MS = 2 * 60 * 1000; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const pendingVotes = await this.prisma.vote.findMany({ - where: { - txId, - voteType: 'APPROVE', - proofStatus: 'PENDING', - }, - }); - - if (pendingVotes.length === 0) { - this.logger.log(`All proofs aggregated for txId: ${txId}`); - break; - } - - this.logger.log( - `Attempt ${attempt + 1}/${maxAttempts}: ${pendingVotes.length} pending proofs for txId: ${txId}`, - ); - - for (const vote of pendingVotes) { - if (!vote.jobId) continue; - - try { - const jobStatus = await this.zkVerifyService.getJobStatus(vote.jobId); - - if (jobStatus.status === 'Aggregated') { - this.logger.log(`job data: ${JSON.stringify(jobStatus)}`); - - // Check if this aggregation is recent (< 2 minutes) - const updatedAt = new Date(jobStatus.updatedAt).getTime(); - const now = Date.now(); - if (now - updatedAt < TWO_MINUTES_MS) { - hasRecentAggregation = true; - } - - await this.prisma.vote.update({ - where: { id: vote.id }, - data: { - proofStatus: 'AGGREGATED', - aggregationId: jobStatus.aggregationId?.toString(), - merkleProof: jobStatus.aggregationDetails?.merkleProof || [], - leafCount: jobStatus.aggregationDetails?.numberOfLeaves, - leafIndex: jobStatus.aggregationDetails?.leafIndex, - }, - }); - - this.logger.log(`Vote ${vote.id} aggregated successfully`); - } else if (jobStatus.status === 'Failed') { - await this.prisma.vote.update({ - where: { id: vote.id }, - data: { proofStatus: 'FAILED' }, - }); - - this.logger.error(`Vote ${vote.id} proof failed`); - } - } catch (error) { - this.logger.error(`Error checking vote ${vote.id}:`, error); - } - } - - await this.sleep(intervalMs); - } - - // Check if all proofs are aggregated - const stillPending = await this.prisma.vote.count({ - where: { - txId, - voteType: 'APPROVE', - proofStatus: 'PENDING', - }, - }); - - if (stillPending > 0) { - this.logger.warn( - `Timeout: ${stillPending} proofs still pending for txId: ${txId}`, - ); - throw new BadRequestException( - `Timeout waiting for proof aggregation. ${stillPending} proofs still pending.`, - ); - } - - // Wait 40s only if there's recent aggregation (< 2 minutes) - if (hasRecentAggregation) { - this.logger.log( - 'Recent aggregation detected, waiting 40s for cross-chain finalization...', - ); - await this.sleep(CROSS_CHAIN_FINALIZATION_WAIT); - } else { - this.logger.log('All aggregations are old (> 2 minutes), skipping wait'); - } - } - private async getApproveCount(txId: number): Promise { return this.prisma.vote.count({ - where: { txId, voteType: 'APPROVE' }, + where: { txId, voteType: VoteType.APPROVE }, }); } private async getDenyCount(txId: number): Promise { return this.prisma.vote.count({ - where: { txId, voteType: 'DENY' }, + where: { txId, voteType: VoteType.DENY }, }); } - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + private logTransactionAnalytics( + txType: TxType, + userAddress: string | undefined, + accountAddress: string, + txHash?: string, + ) { + this.analyticsLogger.logApprove(userAddress, accountAddress, txHash); + + const logByType: Partial void>> = { + [TxType.ADD_SIGNER]: () => + this.analyticsLogger.logAddSigner(userAddress, accountAddress, txHash), + [TxType.REMOVE_SIGNER]: () => + this.analyticsLogger.logRemoveSigner( + userAddress, + accountAddress, + txHash, + ), + [TxType.SET_THRESHOLD]: () => + this.analyticsLogger.logUpdateThreshold( + userAddress, + accountAddress, + txHash, + ), + [TxType.TRANSFER]: () => + this.analyticsLogger.logTransfer(userAddress, accountAddress, txHash), + [TxType.BATCH]: () => + this.analyticsLogger.logBatchTransfer( + userAddress, + accountAddress, + txHash, + ), + }; + + logByType[txType]?.(); + } + + private formatTransactionResponse(tx: any) { + const signerMap = new Map( + tx.account.signers.map((s: any) => [s.user.commitment, s.displayName]), + ); + + let parsedSignerData = null; + if (tx.signerData) { + try { + parsedSignerData = JSON.parse(tx.signerData); + } catch { + parsedSignerData = null; + } + } + + return { + ...tx, + votes: tx.votes.map((vote: any) => ({ + ...vote, + voterName: signerMap.get(vote.voterCommitment) || null, + })), + signerData: parsedSignerData, + account: undefined, + }; } private async getSignerDisplayName( diff --git a/packages/backend/src/user/user.service.ts b/packages/backend/src/user/user.service.ts index 3aed8ea1..67446ed2 100644 --- a/packages/backend/src/user/user.service.ts +++ b/packages/backend/src/user/user.service.ts @@ -75,7 +75,6 @@ export class UserService { }, }); - // Map to response format return accounts.map((account) => ({ id: account.id, address: account.address, @@ -83,7 +82,6 @@ export class UserService { threshold: account.threshold, chainId: account.chainId, createdAt: account.createdAt, - updatedAt: account.updatedAt, signers: account.signers.map((signer) => ({ commitment: signer.user.commitment, name: signer.displayName, diff --git a/packages/backend/src/zkverify/zkverify.service.ts b/packages/backend/src/zkverify/zkverify.service.ts index 8e084b95..d4fe80ff 100644 --- a/packages/backend/src/zkverify/zkverify.service.ts +++ b/packages/backend/src/zkverify/zkverify.service.ts @@ -146,7 +146,7 @@ export class ZkVerifyService { ); if (submitResponse.data.optimisticVerify !== 'success') { - return { jobId: '', status: 'Failed' }; + throw new BadRequestException('Proof verification failed'); } this.logger.log(`Proof submitted, jobId: ${submitResponse.data.jobId}`); diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index c5b6b2e1..195c13c4 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -1,8 +1,4 @@ -import { - resetDatabase, - setupTestApp, - teardownTestApp, -} from '../setup'; +import { resetDatabase, setupTestApp, teardownTestApp } from '../setup'; import { getSignerA, getSignerB } from '../fixtures/test-users'; import { loginUser, AuthTokens } from '../utils/auth.util'; import { TestIdentity, createTestIdentity } from '../utils/identity.util'; @@ -35,7 +31,7 @@ import { } from '../utils/multi-asset-flow.shared'; // Timeout 20 minutes for blockchain calls -jest.setTimeout(1200000); +jest.setTimeout(1200000); describe('Transaction E2E', () => { let identityA: TestIdentity; @@ -108,7 +104,9 @@ describe('Transaction E2E', () => { // 3 single transfers (ETH, ZEN, USDC) for (const amount of scenarioAmounts) { - console.log(`[${amount.scenario.name}] Create single transaction - start`); + console.log( + `[${amount.scenario.name}] Create single transaction - start`, + ); const { nonce } = await apiReserveNonce( tokensA.accessToken, @@ -141,13 +139,21 @@ describe('Transaction E2E', () => { nullifier: votePayloadA.nullifier, ...(amount.scenario.isNative ? {} - : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + : { tokenAddress: amount.scenario.tokenAddress }), }); - createdTxs.push({ kind: 'single', scenario: amount.scenario, amount, txId }); - console.log(`[${amount.scenario.name}] Create single transaction - done`, { + createdTxs.push({ + kind: 'single', + scenario: amount.scenario, + amount, txId, }); + console.log( + `[${amount.scenario.name}] Create single transaction - done`, + { + txId, + }, + ); } // 1 batch tx (ETH + ZEN + USDC, same amounts) @@ -176,7 +182,7 @@ describe('Transaction E2E', () => { identityA, accountAddress, BigInt(batchNonce), - accountAddress as `0x${string}`, + accountAddress, 0n, batchCallData, ); @@ -223,25 +229,26 @@ describe('Transaction E2E', () => { if (txDetails.batchData == null) { throw new Error(`Batch tx ${txId} missing batchData`); } - const parsedBatch = JSON.parse(txDetails.batchData) as ParsedBatchItem[]; + const parsedBatch = JSON.parse( + txDetails.batchData, + ) as ParsedBatchItem[]; const callDataApprove = buildBatchCallDataFromParsed(parsedBatch); const votePayloadB = await generateVotePayload( identityB, accountAddress, BigInt(txDetails.nonce), - accountAddress as `0x${string}`, + accountAddress, 0n, callDataApprove, ); - await apiApproveTransaction( - tokensB.accessToken, - txId, - votePayloadB, - ); + await apiApproveTransaction(tokensB.accessToken, txId, votePayloadB); } else { - const { to: toApprove, value: valueApprove, callData: callDataApprove } = - buildSingleApproveParams(txDetails); + const { + to: toApprove, + value: valueApprove, + callData: callDataApprove, + } = buildSingleApproveParams(txDetails); const votePayloadB = await generateVotePayload( identityB, @@ -251,11 +258,7 @@ describe('Transaction E2E', () => { valueApprove, callDataApprove, ); - await apiApproveTransaction( - tokensB.accessToken, - txId, - votePayloadB, - ); + await apiApproveTransaction(tokensB.accessToken, txId, votePayloadB); } console.log(`[${label}] Approve transaction - done`, { txId }); @@ -297,18 +300,18 @@ describe('Transaction E2E', () => { } | null; expect(finalTx).not.toBeNull(); - expect(finalTx!.status).toBe(TxStatus.EXECUTED); - expect(finalTx!.votes.length).toBe(2); + expect(finalTx.status).toBe(TxStatus.EXECUTED); + expect(finalTx.votes.length).toBe(2); if (entry.kind === 'single') { if (entry.scenario.isNative) { - expect(finalTx!.tokenAddress).toBeNull(); + expect(finalTx.tokenAddress).toBeNull(); } else { - expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + expect(finalTx.tokenAddress?.toLowerCase()).toBe( (entry.scenario.tokenAddress as string).toLowerCase(), ); } - expect(finalTx!.value).toBe(entry.amount.amountString); + expect(finalTx.value).toBe(entry.amount.amountString); } console.log(`[${label}] Final verification - done`, { diff --git a/packages/backend/test/e2e/transaction.staging.e2e-spec.ts b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts index 472f4e3c..d905164f 100644 --- a/packages/backend/test/e2e/transaction.staging.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts @@ -126,7 +126,9 @@ describe('Transaction Staging E2E', () => { // 3 single transfers (ETH, ZEN, USDC) for (const amount of scenarioAmounts) { - console.log(`[${amount.scenario.name}] Create single transaction - start`); + console.log( + `[${amount.scenario.name}] Create single transaction - start`, + ); const { nonce } = await stagingReserveNonce( tokensA.accessToken, @@ -159,13 +161,21 @@ describe('Transaction Staging E2E', () => { nullifier: votePayloadA.nullifier, ...(amount.scenario.isNative ? {} - : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + : { tokenAddress: amount.scenario.tokenAddress }), }); - createdTxs.push({ kind: 'single', scenario: amount.scenario, amount, txId }); - console.log(`[${amount.scenario.name}] Create single transaction - done`, { + createdTxs.push({ + kind: 'single', + scenario: amount.scenario, + amount, txId, }); + console.log( + `[${amount.scenario.name}] Create single transaction - done`, + { + txId, + }, + ); } // 1 batch tx (ETH + ZEN + USDC, same amounts) @@ -183,10 +193,7 @@ describe('Transaction Staging E2E', () => { } console.log('Batch: Create batch items - done', { batchItemIds }); - const batchCallData = buildBatchCallData( - scenarioAmounts, - TEST_RECIPIENT, - ); + const batchCallData = buildBatchCallData(scenarioAmounts, TEST_RECIPIENT); const { nonce: batchNonce } = await stagingReserveNonce( tokensA.accessToken, @@ -197,7 +204,7 @@ describe('Transaction Staging E2E', () => { identityA, accountAddress, BigInt(batchNonce), - accountAddress as `0x${string}`, + accountAddress, 0n, batchCallData, ); @@ -244,14 +251,16 @@ describe('Transaction Staging E2E', () => { if (txDetails.batchData == null) { throw new Error(`Batch tx ${txId} missing batchData`); } - const parsedBatch = JSON.parse(txDetails.batchData) as ParsedBatchItem[]; + const parsedBatch = JSON.parse( + txDetails.batchData, + ) as ParsedBatchItem[]; const callDataApprove = buildBatchCallDataFromParsed(parsedBatch); const votePayloadB = await generateVotePayload( identityB, accountAddress, BigInt(txDetails.nonce), - accountAddress as `0x${string}`, + accountAddress, 0n, callDataApprove, ); @@ -261,8 +270,11 @@ describe('Transaction Staging E2E', () => { votePayloadB, ); } else { - const { to: toApprove, value: valueApprove, callData: callDataApprove } = - buildSingleApproveParams(txDetails); + const { + to: toApprove, + value: valueApprove, + callData: callDataApprove, + } = buildSingleApproveParams(txDetails); const votePayloadB = await generateVotePayload( identityB, @@ -319,17 +331,17 @@ describe('Transaction Staging E2E', () => { } | null; expect(finalTx).not.toBeNull(); - expect(finalTx!.status).toBe(TxStatus.EXECUTED); + expect(finalTx.status).toBe(TxStatus.EXECUTED); if (entry.kind === 'single') { if (entry.scenario.isNative) { - expect(finalTx!.tokenAddress).toBeNull(); + expect(finalTx.tokenAddress).toBeNull(); } else { - expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + expect(finalTx.tokenAddress?.toLowerCase()).toBe( (entry.scenario.tokenAddress as string).toLowerCase(), ); } - expect(finalTx!.value).toBe(entry.amount.amountString); + expect(finalTx.value).toBe(entry.amount.amountString); } console.log(`[${label}] Final verification - done`, { @@ -342,4 +354,3 @@ describe('Transaction Staging E2E', () => { }); }); }); - diff --git a/packages/backend/test/fixtures/test-users.ts b/packages/backend/test/fixtures/test-users.ts index 82d88786..2264d45b 100644 --- a/packages/backend/test/fixtures/test-users.ts +++ b/packages/backend/test/fixtures/test-users.ts @@ -15,11 +15,11 @@ export interface TestUser { export function getSignerA(): TestUser { const privateKey = process.env.TEST_SIGNER_A_KEY; if (!privateKey) { - throw new Error("TEST_SIGNER_A_KEY environment variable is not set"); + throw new Error('TEST_SIGNER_A_KEY environment variable is not set'); } return { privateKey: privateKey as `0x${string}`, - name: "Signer A", + name: 'Signer A', }; } @@ -30,10 +30,10 @@ export function getSignerA(): TestUser { export function getSignerB(): TestUser { const privateKey = process.env.TEST_SIGNER_B_KEY; if (!privateKey) { - throw new Error("TEST_SIGNER_B_KEY environment variable is not set"); + throw new Error('TEST_SIGNER_B_KEY environment variable is not set'); } return { privateKey: privateKey as `0x${string}`, - name: "Signer B", + name: 'Signer B', }; } diff --git a/packages/backend/test/setup.ts b/packages/backend/test/setup.ts index f4dc11ad..07d4bb51 100644 --- a/packages/backend/test/setup.ts +++ b/packages/backend/test/setup.ts @@ -1,7 +1,7 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { INestApplication, ValidationPipe } from "@nestjs/common"; -import { AppModule } from "../src/app.module"; -import { truncateAllTables } from "./utils/cleanup.util"; +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { AppModule } from '../src/app.module'; +import { truncateAllTables } from './utils/cleanup.util'; let app: INestApplication; @@ -21,11 +21,11 @@ export async function setupTestApp(): Promise { new ValidationPipe({ whitelist: true, transform: true, - }) + }), ); // Set global prefix - app.setGlobalPrefix("api"); + app.setGlobalPrefix('api'); await app.init(); @@ -37,7 +37,7 @@ export async function setupTestApp(): Promise { */ export function getTestApp(): INestApplication { if (!app) { - throw new Error("Test app not initialized. Call setupTestApp() first."); + throw new Error('Test app not initialized. Call setupTestApp() first.'); } return app; } @@ -47,7 +47,7 @@ export function getTestApp(): INestApplication { */ export function getHttpServer() { if (!app) { - throw new Error("Test app not initialized. Call setupTestApp() first."); + throw new Error('Test app not initialized. Call setupTestApp() first.'); } return app.getHttpServer(); } @@ -66,7 +66,7 @@ export async function teardownTestApp(): Promise { */ export async function resetDatabase(): Promise { if (!app) { - throw new Error("Test app not initialized. Call setupTestApp() first."); + throw new Error('Test app not initialized. Call setupTestApp() first.'); } await truncateAllTables(app); } diff --git a/packages/backend/test/utils/cleanup.util.ts b/packages/backend/test/utils/cleanup.util.ts index a7160065..0871cf6d 100644 --- a/packages/backend/test/utils/cleanup.util.ts +++ b/packages/backend/test/utils/cleanup.util.ts @@ -1,5 +1,5 @@ -import { PrismaService } from "@/database/prisma.service"; -import { INestApplication } from "@nestjs/common"; +import { PrismaService } from '@/database/prisma.service'; +import { INestApplication } from '@nestjs/common'; /** * Truncate all tables in the test database @@ -18,7 +18,7 @@ export async function truncateAllTables(app: INestApplication): Promise { await prisma.claimHistory.deleteMany({}); await prisma.user.deleteMany({}); - console.log("All tables truncated"); + console.log('All tables truncated'); } /** diff --git a/packages/backend/test/utils/contract.util.ts b/packages/backend/test/utils/contract.util.ts index 264de01b..3fdc222a 100644 --- a/packages/backend/test/utils/contract.util.ts +++ b/packages/backend/test/utils/contract.util.ts @@ -1,5 +1,10 @@ import { createPublicClient, http, type Hex, parseEther } from 'viem'; -import { horizenMainnet, horizenTestnet, METAMULTISIG_ABI, NetworkValue } from '@polypay/shared'; +import { + horizenMainnet, + horizenTestnet, + METAMULTISIG_ABI, + NetworkValue, +} from '@polypay/shared'; import { TestSigner } from './signer.util'; import { waitForReceiptWithRetry } from '@/common/utils/retry'; @@ -39,7 +44,7 @@ export async function getTransactionHash( args: [nonce, to, value, data], }); - return txHash as Hex; + return txHash; } /** diff --git a/packages/backend/test/utils/identity.util.ts b/packages/backend/test/utils/identity.util.ts index 0454fe02..721de9fa 100644 --- a/packages/backend/test/utils/identity.util.ts +++ b/packages/backend/test/utils/identity.util.ts @@ -31,4 +31,3 @@ export async function createTestIdentity( }, }; } - diff --git a/packages/backend/test/utils/multi-asset-flow.shared.ts b/packages/backend/test/utils/multi-asset-flow.shared.ts index de7a16b4..a9c6b705 100644 --- a/packages/backend/test/utils/multi-asset-flow.shared.ts +++ b/packages/backend/test/utils/multi-asset-flow.shared.ts @@ -85,7 +85,7 @@ export function buildSingleTransferParams( ): { to: `0x${string}`; value: bigint; callData: Hex } { const to = amount.scenario.isNative ? recipient - : (amount.scenario.tokenAddress as `0x${string}`); + : amount.scenario.tokenAddress; const value = amount.scenario.isNative ? amount.amountBigInt : 0n; const callData = amount.scenario.isNative ? ('0x' as Hex) @@ -114,20 +114,16 @@ export function buildBatchCallData( scenarioAmounts: ScenarioAmount[], recipient: `0x${string}`, ): Hex { - const recipients = [ - recipient, - recipient, - recipient, - ] as `0x${string}`[]; + const recipients = [recipient, recipient, recipient] as `0x${string}`[]; const amounts = scenarioAmounts.map((a) => a.amountBigInt); - const tokenAddresses = scenarioAmounts.map((a) => - a.scenario.tokenAddress ?? (ZERO_ADDRESS as `0x${string}`), + const tokenAddresses = scenarioAmounts.map( + (a) => a.scenario.tokenAddress ?? (ZERO_ADDRESS as `0x${string}`), ); return encodeBatchTransferMulti( recipients, amounts, tokenAddresses as string[], - ) as Hex; + ); } export function buildBatchCallDataFromParsed( @@ -135,14 +131,8 @@ export function buildBatchCallDataFromParsed( ): Hex { const recipients = parsedBatch.map((p) => p.recipient as `0x${string}`); const amounts = parsedBatch.map((p) => BigInt(p.amount)); - const tokenAddresses = parsedBatch.map( - (p) => p.tokenAddress || ZERO_ADDRESS, - ); - return encodeBatchTransferMulti( - recipients, - amounts, - tokenAddresses, - ) as Hex; + const tokenAddresses = parsedBatch.map((p) => p.tokenAddress || ZERO_ADDRESS); + return encodeBatchTransferMulti(recipients, amounts, tokenAddresses); } /** @@ -217,10 +207,7 @@ export async function fundAccountForScenario( balanceBefore.toString(), scenario.decimals, ), - balanceAfter: formatTokenAmount( - balanceAfter.toString(), - scenario.decimals, - ), + balanceAfter: formatTokenAmount(balanceAfter.toString(), scenario.decimals), }); return { diff --git a/packages/backend/test/utils/proof.util.ts b/packages/backend/test/utils/proof.util.ts index d7d5b1a5..8c9afde9 100644 --- a/packages/backend/test/utils/proof.util.ts +++ b/packages/backend/test/utils/proof.util.ts @@ -93,8 +93,8 @@ export async function generateTestProof( const { bytecode, abi } = loadCircuit('circuit'); // Dynamic import Noir libraries - const { Noir } = await import("@noir-lang/noir_js"); - const { UltraPlonkBackend } = await import("@aztec/bb.js"); + const { Noir } = await import('@noir-lang/noir_js'); + const { UltraPlonkBackend } = await import('@aztec/bb.js'); // Initialize Noir and backend const backend = new UltraPlonkBackend(bytecode); @@ -104,7 +104,7 @@ export async function generateTestProof( const { witness } = await noir.execute(circuitInputs); // Generate proof - const {proof, publicInputs} = await backend.generateProof(witness); + const { proof, publicInputs } = await backend.generateProof(witness); // 7. Format output const proofArray = Array.from(proof); diff --git a/packages/backend/test/utils/signer.util.ts b/packages/backend/test/utils/signer.util.ts index 313f4a7e..4128f868 100644 --- a/packages/backend/test/utils/signer.util.ts +++ b/packages/backend/test/utils/signer.util.ts @@ -1,12 +1,7 @@ -import { - createWalletClient, - http, - type WalletClient, - type Hex, -} from "viem"; -import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; -import { type TestUser } from "../fixtures/test-users"; -import { horizenMainnet, horizenTestnet, NetworkValue } from "@polypay/shared"; +import { createWalletClient, http, type WalletClient, type Hex } from 'viem'; +import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts'; +import { type TestUser } from '../fixtures/test-users'; +import { horizenMainnet, horizenTestnet, NetworkValue } from '@polypay/shared'; /** * Test signer with wallet client and account info @@ -49,7 +44,7 @@ export function createTestSigner(testUser: TestUser): TestSigner { */ export async function signMessage( signer: TestSigner, - message: string | { raw: Hex } + message: string | { raw: Hex }, ): Promise { // Use account.signMessage for local signing (no RPC call) const signature = await signer.account.signMessage({ @@ -66,7 +61,7 @@ export async function signMessage( */ export async function signRawMessage( signer: TestSigner, - rawBytes: Hex + rawBytes: Hex, ): Promise { const signature = await signer.account.signMessage({ message: { raw: rawBytes }, diff --git a/packages/backend/test/utils/staging-api.util.ts b/packages/backend/test/utils/staging-api.util.ts index ad7699d3..968fadba 100644 --- a/packages/backend/test/utils/staging-api.util.ts +++ b/packages/backend/test/utils/staging-api.util.ts @@ -1,10 +1,10 @@ import axios from 'axios'; -import { - API_ENDPOINTS, - CreateAccountDto, - TxType, -} from '@polypay/shared'; -import type { CreateTransactionPayload, ApproveTransactionPayload, CreateBatchItemPayload } from './transaction.util'; +import { API_ENDPOINTS, CreateAccountDto, TxType } from '@polypay/shared'; +import type { + CreateTransactionPayload, + ApproveTransactionPayload, + CreateBatchItemPayload, +} from './transaction.util'; import type { AuthTokens } from './auth.util'; import { generateTestAuthProof } from './proof.util'; @@ -21,14 +21,11 @@ export async function stagingLogin( ): Promise { const authProof = await generateTestAuthProof(secret); - const response = await axios.post( - `${BASE_URL}${API_ENDPOINTS.auth.login}`, - { - commitment, - proof: authProof.proof, - publicInputs: authProof.publicInputs, - }, - ); + const response = await axios.post(`${BASE_URL}${API_ENDPOINTS.auth.login}`, { + commitment, + proof: authProof.proof, + publicInputs: authProof.publicInputs, + }); return { accessToken: response.data.accessToken, @@ -173,10 +170,7 @@ export async function stagingExecuteTransaction( } } -export async function stagingGetTransaction( - accessToken: string, - txId: string, -) { +export async function stagingGetTransaction(accessToken: string, txId: string) { try { const response = await axios.get( `${BASE_URL}${API_ENDPOINTS.transactions.byTxId(Number(txId))}`, @@ -194,4 +188,3 @@ export async function stagingGetTransaction( throw error; } } - diff --git a/packages/backend/test/utils/transaction.util.ts b/packages/backend/test/utils/transaction.util.ts index 695846aa..22458d44 100644 --- a/packages/backend/test/utils/transaction.util.ts +++ b/packages/backend/test/utils/transaction.util.ts @@ -1,10 +1,6 @@ import * as request from 'supertest'; import { type Hex } from 'viem'; -import { - API_ENDPOINTS, - CreateAccountDto, - TxType, -} from '@polypay/shared'; +import { API_ENDPOINTS, CreateAccountDto, TxType } from '@polypay/shared'; import { parseTokenAmount } from '@polypay/shared'; import { getHttpServer } from '../setup'; import { getAuthHeader } from './auth.util'; @@ -99,10 +95,7 @@ export async function apiApproveTransaction( .expect(201); } -export async function apiExecuteTransaction( - accessToken: string, - txId: string, -) { +export async function apiExecuteTransaction(accessToken: string, txId: string) { const server = getHttpServer(); const response = await request(server) @@ -161,7 +154,11 @@ export async function generateVotePayload( callData, ); - const proof = await generateTestProof(identity.signer, identity.secret, txHash); + const proof = await generateTestProof( + identity.signer, + identity.secret, + txHash, + ); return { proof: proof.proof, @@ -217,7 +214,7 @@ export async function transferErc20FromSigner( const publicClient = createTestPublicClient(); await waitForReceiptWithRetry(publicClient as any, hash); - return hash as Hex; + return hash; } /** @@ -244,7 +241,5 @@ export async function getErc20Balance( args: [accountAddress], }); - return balance as bigint; + return balance; } - - diff --git a/packages/backend/webpack.config.js b/packages/backend/webpack.config.js new file mode 100644 index 00000000..cf344aee --- /dev/null +++ b/packages/backend/webpack.config.js @@ -0,0 +1,19 @@ +const path = require('path'); + +module.exports = function (options) { + return { + ...options, + watchOptions: { + ignored: [ + '**/node_modules/**', + '**/dist/**', + '**/logs/**', + '**/.git/**', + '**/assets/**', + '**/test/**', + ], + aggregateTimeout: 300, + poll: false, + }, + }; +}; diff --git a/packages/nextjs/app/contact-book/page.tsx b/packages/nextjs/app/contact-book/page.tsx index 6c293322..b12f7e53 100644 --- a/packages/nextjs/app/contact-book/page.tsx +++ b/packages/nextjs/app/contact-book/page.tsx @@ -4,12 +4,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { Contact, ContactGroup } from "@polypay/shared"; -import { Search } from "lucide-react"; +import { ChevronDown, Search, Upload, Users } from "lucide-react"; import { ContactDetailDrawer } from "~~/components/contact-book/ContactDetailDrawer"; import { ContactList } from "~~/components/contact-book/ContactList"; import { EditContact } from "~~/components/contact-book/Editcontact"; import { modalManager } from "~~/components/modals/ModalLayout"; import { useContacts, useGroups } from "~~/hooks"; +import { useClickOutside } from "~~/hooks/useClickOutside"; import { useAccountStore } from "~~/services/store"; import { formatAddress } from "~~/utils/format"; @@ -23,7 +24,10 @@ export default function AddressBookPage() { const [searchTerm, setSearchTerm] = useState(""); const [editing, setEditing] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [batchDropdownOpen, setBatchDropdownOpen] = useState(false); const searchInputRef = useRef(null); + const batchDropdownRef = useRef(null); + useClickOutside(batchDropdownRef, () => setBatchDropdownOpen(false), { isActive: batchDropdownOpen }); const { data: groups = [], refetch: refetchGroups } = useGroups(accountId); const { @@ -131,17 +135,40 @@ export default function AddressBookPage() { New group New group - +
+ + {batchDropdownOpen && ( +
+ + +
+ )} +
- ); - } - - return ( -
- - -
- ); -} - -// ============ Transaction Display Components ============ -interface TxHeaderProps { - tx: TransactionRowData; - myVoteStatus: VoteStatus | null; - onApprove: () => void; - onDeny: () => void; - loading: boolean; - initiatorName?: string; - initiatorCommitment: string; -} - -function TxHeader({ - tx, - myVoteStatus, - onApprove, - onDeny, - loading, - initiatorCommitment, - initiatorName, -}: TxHeaderProps & { initiatorCommitment?: string }) { - const headerText = getExpandedHeaderText(tx.type); - const shortCommitment = formatAddress(initiatorCommitment, { start: 4, end: 4 }); - const { chainId } = useNetworkTokens(); - - const renderHeaderRow = () => ( -
-
- {tx.type === TxType.BATCH ? ( - {tx.batchData?.length ?? 0} transactions - ) : ( - - {headerText} {initiatorName ? `${initiatorName} (${shortCommitment})` : shortCommitment} - - )} -
- {myVoteStatus === null && ( -
- - -
- )} -
- ); - - if (tx.type === TxType.TRANSFER) { - return ( -
- {renderHeaderRow()} -
- Tranfer - {getTokenByAddress(tx.tokenAddress, - {formatAmount(tx.amount ?? "0", chainId, tx.tokenAddress)} - Arrow Right - -
-
- ); - } - - if (tx.type === TxType.ADD_SIGNER) { - return ( -
- {renderHeaderRow()} -
- {tx.signerData?.map((signer, index) => ( -
- Signer - - {signer.name - ? `${signer.name} (${formatAddress(signer.commitment, { start: 4, end: 4 })})` - : formatAddress(signer.commitment, { start: 4, end: 4 })} - -
- ))} -
- {/* Threshold update */} - {tx.newThreshold && tx.newThreshold !== tx.threshold && ( -
- Threshold update: - {String(tx.oldThreshold).padStart(2, "0")} - Arrow Right - {String(tx.newThreshold).padStart(2, "0")} -
- )} -
- ); - } - - if (tx.type === TxType.REMOVE_SIGNER) { - return ( -
- {renderHeaderRow()} -
- {tx.signerData?.map((signer, index) => ( -
- Signer - - {signer.name - ? `${signer.name} (${formatAddress(signer.commitment, { start: 4, end: 4 })})` - : formatAddress(signer.commitment, { start: 4, end: 4 })} - -
- ))} -
- {/* Threshold update */} - {tx.newThreshold && tx.newThreshold !== tx.threshold && ( -
- Threshold update: - {String(tx.oldThreshold).padStart(2, "0")} - Arrow Right - {String(tx.newThreshold).padStart(2, "0")} -
- )} -
- ); - } - - if (tx.type === TxType.SET_THRESHOLD) { - return ( -
- {renderHeaderRow()} -
- New Threshold - {String(tx.oldThreshold).padStart(2, "0")} - Arrow Right - {String(tx.newThreshold).padStart(2, "0")} -
-
- ); - } - - if (tx.type === TxType.BATCH && tx.batchData) { - return ( -
- {renderHeaderRow()} -
- {tx.batchData.map((transfer, index) => ( -
- Tranfer - {getTokenByAddress(transfer.tokenAddress, - {formatAmount(transfer.amount ?? "0", chainId, transfer.tokenAddress)} - Arrow Right - -
- ))} -
-
- ); - } - - return null; -} - -interface SignerWithStatus { - commitment: string; - name?: string | null; - isInitiator: boolean; - isMe: boolean; - voteStatus: VoteStatus | "waiting"; -} - -function SignerList({ - members, - allSigners, - votedCount, - threshold, - totalSigners, - myCommitment, - initiatorCommitment, - txStatus, -}: { - members: Member[]; - allSigners: string[]; - votedCount: number; - threshold: number; - totalSigners: number; - myCommitment: string; - initiatorCommitment: string; - txStatus: TxStatus; -}) { - // If tx is executed or failed, only show voters from members - // Otherwise, merge allSigners with vote status - const signersWithStatus: SignerWithStatus[] = - txStatus === TxStatus.EXECUTED || txStatus === TxStatus.FAILED - ? members.map(member => ({ - commitment: member.commitment, - name: member.name || null, - isInitiator: member.commitment === initiatorCommitment, - isMe: member.commitment === myCommitment, - voteStatus: member.voteStatus, - })) - : allSigners.map(commitment => { - const voted = members.find(m => m.commitment === commitment); - return { - commitment, - name: voted?.name || null, - isInitiator: commitment === initiatorCommitment, - isMe: commitment === myCommitment, - voteStatus: voted?.voteStatus || "waiting", - }; - }); - - return ( -
- {/* Header */} -
- Signers -
- - Voted{" "} - - {votedCount}/{totalSigners} - - - - Threshold{" "} - - {threshold}/{totalSigners} - - -
-
- - {/* Signer Rows */} -
- {signersWithStatus.map((signer, index) => ( -
-
- - {signer.name ? ( - <> - {signer.name} ({formatAddress(signer.commitment, { start: 4, end: 4 })}) - - ) : ( - formatAddress(signer.commitment, { start: 4, end: 4 }) - )} - - {signer.isMe && [you]} - {signer.isInitiator && ( - [Transaction Initiator] - )} -
- -
- ))} -
-
- ); -} - -function getTxTypeLabel(type: TxType): string { - switch (type) { - case TxType.TRANSFER: - return "Transfer"; - case TxType.ADD_SIGNER: - return "Add Signer"; - case TxType.REMOVE_SIGNER: - return "Remove Signer"; - case TxType.SET_THRESHOLD: - return "Threshold"; - case TxType.BATCH: - return "Batch"; - } -} - -// Get header text for expanded content based on tx type -function getExpandedHeaderText(type: TxType): string { - switch (type) { - case TxType.ADD_SIGNER: - return "Added by"; - case TxType.REMOVE_SIGNER: - return "Removed by"; - case TxType.SET_THRESHOLD: - return "Updated by"; - case TxType.TRANSFER: - return "Created by"; - case TxType.BATCH: - return ""; - default: - return "Created by"; - } -} - -function TxDetails({ tx }: { tx: TransactionRowData }) { - const { chainId } = useNetworkTokens(); - switch (tx.type) { - case TxType.TRANSFER: - return ( -
-
- {getTokenByAddress(tx.tokenAddress, - {formatAmount(tx.amount ?? "0", chainId, tx.tokenAddress)} -
- Arrow Right - -
- ); - - case TxType.ADD_SIGNER: - case TxType.REMOVE_SIGNER: - return ( -
- {tx.signerData?.map((signer, index) => ( -
- Signer - - {signer.name ? ( - <> - {signer.name} ({formatAddress(signer.commitment, { start: 4, end: 4 })}) - - ) : ( - formatAddress(signer.commitment, { start: 4, end: 4 }) - )} - -
- ))} -
- ); - - case TxType.SET_THRESHOLD: - return ( -
- {String(tx.oldThreshold).padStart(2, "0")} - Arrow Right - {String(tx.newThreshold).padStart(2, "0")} -
- ); - - case TxType.BATCH: - if (!tx.batchData || tx.batchData.length === 0) { - return No transfers; - } - return ( -
- - {tx.batchData.length} transfer{tx.batchData.length > 1 ? "s" : ""} - -
- ); - } -} - -// ============ Main Component ============ -interface TransactionRowProps { - tx: TransactionRowData; - onSuccess?: () => void; -} - -export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { - const [expanded, setExpanded] = useState(false); - const [isExecutable, setIsExecutable] = useState(false); - - const { data: walletThreshold } = useWalletThreshold(); - const { data: commitmentsData } = useWalletCommitments(); - - // Get totalSigners realtime from wallet commitments - const totalSigners = commitmentsData?.length || 0; - - const { - approve, - deny, - execute, - isLoading: loading, - loadingState, - loadingStep, - totalSteps, - } = useTransactionVote({ - onSuccess, - }); - - const handleApprove = async () => { - await approve(tx); - }; - - const handleDeny = async () => { - await deny(tx); - }; - - const handleExecute = async (txId: number) => { - await execute(txId); - }; - - const renderRightSide = () => { - // Executed or Failed - if (tx.status === TxStatus.EXECUTED || tx.status === TxStatus.FAILED) { - return ; - } - - // Can execute - if (isExecutable || tx.status === TxStatus.EXECUTING) { - return ( - handleExecute(tx.txId)} - loading={loading} - isExecutable={isExecutable && tx.status !== TxStatus.EXECUTING} - isExecuting={tx.status === TxStatus.EXECUTING} - /> - ); - } - - // Not voted yet - show action buttons - if (tx.myVoteStatus === null) { - // Expanded → show Awaiting (buttons are in TxHeader) - if (expanded) { - return ; - } - // Collapsed → show action buttons - return ( - handleExecute(tx.txId)} - loading={loading} - isExecutable={false} - isExecuting={false} - /> - ); - } - - // Voted but waiting for others - return ; - }; - - // Get initiator name for header - const initiator = tx.members.find(m => m.isInitiator); - const initiatorName = initiator - ? initiator.name || formatAddress(initiator.commitment, { start: 4, end: 4 }) - : "Unknown"; - const initiatorCommitment = tx.members.find(m => m.isInitiator)?.commitment || ""; - - useEffect(() => { - const checkExecutable = async () => { - if (tx.status !== TxStatus.PENDING) { - setIsExecutable(false); - return; - } - - try { - setIsExecutable(tx.approveCount >= Number(walletThreshold)); - } catch (error) { - console.error("Failed to check threshold:", error); - setIsExecutable(false); - } - }; - - checkExecutable(); - }, [tx.status, tx.approveCount, walletThreshold]); - - return ( -
- {/* Loading State */} - {loading && loadingState && ( -
-
- {loadingStep > 0 && totalSteps > 1 && ( - - Step {loadingStep}/{totalSteps} - - )} - {loadingState} -
-
- )} - - {/* Main Container */} -
setExpanded(!expanded)} - > - {/* Collapsed Row */} -
-
- {expanded ? ( - - ) : ( - - )} - {getTxTypeLabel(tx.type)} - {!expanded && } -
-
e.stopPropagation()}>{renderRightSide()}
-
- - {/* Expanded Content */} - {expanded && ( -
- - item?.toString()) || []} - votedCount={tx.votedCount} - threshold={ - tx.status === TxStatus.EXECUTED || tx.status === TxStatus.FAILED - ? tx.approveCount - : Number(walletThreshold) || 0 - } - totalSigners={totalSigners} - myCommitment={tx.members.find(m => m.isMe)?.commitment || ""} - initiatorCommitment={initiatorCommitment} - txStatus={tx.status} - /> -
- )} -
-
- ); -} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/AddressWithContact.tsx b/packages/nextjs/components/Dashboard/TransactionRow/AddressWithContact.tsx new file mode 100644 index 00000000..4a892585 --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/AddressWithContact.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { formatAddress } from "~~/utils/format"; + +export function AddressWithContact({ + address, + contactName, + className, +}: { + address: string; + contactName?: string; + className?: string; +}) { + if (contactName) { + return ( + + {contactName} + ({formatAddress(address, { start: 3, end: 3 })}) + + ); + } + return ( + + {formatAddress(address, { start: 3, end: 3 })} + + ); +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/Badges.tsx b/packages/nextjs/components/Dashboard/TransactionRow/Badges.tsx new file mode 100644 index 00000000..0b9e62c9 --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/Badges.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { TxStatus } from "@polypay/shared"; +import { ExternalLink } from "lucide-react"; +import { VoteStatus, useNetworkTokens } from "~~/hooks"; +import scaffoldConfig from "~~/scaffold.config"; +import { getBlockExplorerTxHashLink, getBlockExplorerTxLink } from "~~/utils/scaffold-eth/networks"; + +export function VoteBadge({ status }: { status: VoteStatus | "waiting" }) { + if (status === "approved") { + return ( + + Approved + + ); + } + if (status === "denied") { + return ( + + Denied + + ); + } + return ( + + Waiting for confirm... + + ); +} + +export function AwaitingBadge() { + return ( + + Awaiting + + ); +} + +export function StatusBadge({ status, txHash }: { status: TxStatus; txHash?: string }) { + const { chainId } = useNetworkTokens(); + + if (status === TxStatus.EXECUTED) { + let explorerUrl = "#"; + + if (txHash && chainId) { + const targetNetwork = scaffoldConfig.targetNetworks.find(network => network.id === chainId); + + if (targetNetwork) { + explorerUrl = getBlockExplorerTxHashLink(targetNetwork, txHash); + } else { + explorerUrl = getBlockExplorerTxLink(chainId, txHash) || "#"; + } + } + + return ( + + Succeed + + + ); + } + if (status === TxStatus.FAILED) { + return ( + + Denied + + ); + } + return null; +} + +export function ActionButtons({ + onApprove, + onDeny, + onExecute, + loading, + isExecutable, + isExecuting, +}: { + onApprove: () => void; + onDeny: () => void; + onExecute: () => void; + loading: boolean; + isExecutable: boolean; + isExecuting: boolean; +}) { + if (isExecutable || isExecuting) { + return ( + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx b/packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx new file mode 100644 index 00000000..b3effcdd --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/BatchRowMenu.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Contact, ZERO_ADDRESS, formatTokenAmount, getTokenByAddress } from "@polypay/shared"; +import { Copy, MoreVertical } from "lucide-react"; +import { BatchContactEntry } from "~~/components/modals/CreateBatchFromContactsModal"; +import { modalManager } from "~~/components/modals/ModalLayout"; +import { BatchTransfer } from "~~/hooks"; +import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; +import { useClickOutside } from "~~/hooks/useClickOutside"; +import { useAccountStore } from "~~/services/store"; +import { formatAddress } from "~~/utils/format"; + +interface BatchRowMenuProps { + batchData: BatchTransfer[]; +} + +export function BatchRowMenu({ batchData }: BatchRowMenuProps) { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const { chainId } = useNetworkTokens(); + const { currentAccount } = useAccountStore(); + + useClickOutside(containerRef, () => setOpen(false), { isActive: open }); + + const handleDuplicate = () => { + setOpen(false); + + const initialBatchItems: BatchContactEntry[] = batchData.map(transfer => { + const token = getTokenByAddress(transfer.tokenAddress, chainId); + const amount = formatTokenAmount(transfer.amount, token.decimals); + + const contact: Contact = { + id: crypto.randomUUID(), + name: transfer.contactName || formatAddress(transfer.recipient, { start: 6, end: 4 }), + address: transfer.recipient, + accountId: "", + groups: [], + createdAt: "", + updatedAt: "", + } as Contact; + + return { + contact, + amount, + tokenAddress: transfer.tokenAddress || ZERO_ADDRESS, + isSynthetic: true, + }; + }); + + modalManager.openModal?.("createBatchFromContacts", { + accountId: currentAccount?.id, + initialBatchItems, + }); + }; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/SignerList.tsx b/packages/nextjs/components/Dashboard/TransactionRow/SignerList.tsx new file mode 100644 index 00000000..e3ea6ea8 --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/SignerList.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { VoteBadge } from "./Badges"; +import { TxStatus } from "@polypay/shared"; +import { Member, VoteStatus } from "~~/hooks"; +import { formatAddress } from "~~/utils/format"; + +interface SignerWithStatus { + commitment: string; + name?: string | null; + isInitiator: boolean; + isMe: boolean; + voteStatus: VoteStatus | "waiting"; +} + +export function SignerList({ + members, + allSigners, + votedCount, + threshold, + totalSigners, + myCommitment, + initiatorCommitment, + txStatus, +}: { + members: Member[]; + allSigners: string[]; + votedCount: number; + threshold: number; + totalSigners: number; + myCommitment: string; + initiatorCommitment: string; + txStatus: TxStatus; +}) { + const signersWithStatus: SignerWithStatus[] = + txStatus === TxStatus.EXECUTED || txStatus === TxStatus.FAILED + ? members.map(member => ({ + commitment: member.commitment, + name: member.name || null, + isInitiator: member.commitment === initiatorCommitment, + isMe: member.commitment === myCommitment, + voteStatus: member.voteStatus, + })) + : allSigners.map(commitment => { + const voted = members.find(m => m.commitment === commitment); + return { + commitment, + name: voted?.name || null, + isInitiator: commitment === initiatorCommitment, + isMe: commitment === myCommitment, + voteStatus: voted?.voteStatus || "waiting", + }; + }); + + return ( +
+
+ Signers +
+ + Voted{" "} + + {votedCount}/{totalSigners} + + + + Threshold{" "} + + {threshold}/{totalSigners} + + +
+
+ +
+ {signersWithStatus.map((signer, index) => ( +
+
+ + {signer.name ? ( + <> + {signer.name} ({formatAddress(signer.commitment, { start: 4, end: 4 })}) + + ) : ( + formatAddress(signer.commitment, { start: 4, end: 4 }) + )} + + {signer.isMe && [you]} + {signer.isInitiator && ( + [Transaction Initiator] + )} +
+ +
+ ))} +
+
+ ); +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/TxDetails.tsx b/packages/nextjs/components/Dashboard/TransactionRow/TxDetails.tsx new file mode 100644 index 00000000..b6dc64ef --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/TxDetails.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import Image from "next/image"; +import { AddressWithContact } from "./AddressWithContact"; +import { TxType, getTokenByAddress } from "@polypay/shared"; +import { TransactionRowData, useNetworkTokens } from "~~/hooks"; +import { formatAddress, formatAmount } from "~~/utils/format"; + +export function TxDetails({ tx }: { tx: TransactionRowData }) { + const { chainId } = useNetworkTokens(); + switch (tx.type) { + case TxType.TRANSFER: + return ( +
+
+ {getTokenByAddress(tx.tokenAddress, + {formatAmount(tx.amount ?? "0", chainId, tx.tokenAddress)} +
+ Arrow Right + +
+ ); + + case TxType.ADD_SIGNER: + case TxType.REMOVE_SIGNER: + return ( +
+ {tx.signerData?.map((signer, index) => ( +
+ Signer + + {signer.name ? ( + <> + {signer.name} ({formatAddress(signer.commitment, { start: 4, end: 4 })}) + + ) : ( + formatAddress(signer.commitment, { start: 4, end: 4 }) + )} + +
+ ))} +
+ ); + + case TxType.SET_THRESHOLD: + return ( +
+ {String(tx.oldThreshold).padStart(2, "0")} + Arrow Right + {String(tx.newThreshold).padStart(2, "0")} +
+ ); + + case TxType.BATCH: + if (!tx.batchData || tx.batchData.length === 0) { + return No transfers; + } + return ( +
+ + {tx.batchData.length} transfer{tx.batchData.length > 1 ? "s" : ""} + +
+ ); + } +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx b/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx new file mode 100644 index 00000000..2b33661e --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/TxHeader.tsx @@ -0,0 +1,175 @@ +import React from "react"; +import Image from "next/image"; +import { AddressWithContact } from "./AddressWithContact"; +import { getExpandedHeaderText } from "./utils"; +import { TxType, getTokenByAddress } from "@polypay/shared"; +import { TransactionRowData, VoteStatus, useNetworkTokens } from "~~/hooks"; +import { formatAddress, formatAmount } from "~~/utils/format"; + +interface TxHeaderProps { + tx: TransactionRowData; + myVoteStatus: VoteStatus | null; + onApprove: () => void; + onDeny: () => void; + loading: boolean; + initiatorName?: string; + initiatorCommitment: string; +} + +function SignerBadgeList({ signerData }: { signerData: TransactionRowData["signerData"] }) { + return ( +
+ {signerData?.map((signer, index) => ( +
+ Signer + + {signer.name + ? `${signer.name} (${formatAddress(signer.commitment, { start: 4, end: 4 })})` + : formatAddress(signer.commitment, { start: 4, end: 4 })} + +
+ ))} +
+ ); +} + +function ThresholdUpdate({ oldThreshold, newThreshold }: { oldThreshold?: number; newThreshold?: number }) { + if (!newThreshold || newThreshold === oldThreshold) return null; + return ( +
+ Threshold update: + {String(oldThreshold).padStart(2, "0")} + Arrow Right + {String(newThreshold).padStart(2, "0")} +
+ ); +} + +export function TxHeader({ + tx, + myVoteStatus, + onApprove, + onDeny, + loading, + initiatorCommitment, + initiatorName, +}: TxHeaderProps) { + const headerText = getExpandedHeaderText(tx.type); + const shortCommitment = formatAddress(initiatorCommitment, { start: 4, end: 4 }); + const { chainId } = useNetworkTokens(); + + const renderHeaderRow = () => ( +
+
+ {tx.type === TxType.BATCH ? ( + {tx.batchData?.length ?? 0} transactions + ) : ( + + {headerText} {initiatorName ? `${initiatorName} (${shortCommitment})` : shortCommitment} + + )} +
+ {myVoteStatus === null && ( +
+ + +
+ )} +
+ ); + + if (tx.type === TxType.TRANSFER) { + return ( +
+ {renderHeaderRow()} +
+ Tranfer + {getTokenByAddress(tx.tokenAddress, + {formatAmount(tx.amount ?? "0", chainId, tx.tokenAddress)} + Arrow Right + +
+
+ ); + } + + if (tx.type === TxType.ADD_SIGNER || tx.type === TxType.REMOVE_SIGNER) { + return ( +
+ {renderHeaderRow()} + + +
+ ); + } + + if (tx.type === TxType.SET_THRESHOLD) { + return ( +
+ {renderHeaderRow()} +
+ New Threshold + {String(tx.oldThreshold).padStart(2, "0")} + Arrow Right + {String(tx.newThreshold).padStart(2, "0")} +
+
+ ); + } + + if (tx.type === TxType.BATCH && tx.batchData) { + return ( +
+ {renderHeaderRow()} +
+ {tx.batchData.map((transfer, index) => ( +
+ Tranfer + {getTokenByAddress(transfer.tokenAddress, + {formatAmount(transfer.amount ?? "0", chainId, transfer.tokenAddress)} + Arrow Right + +
+ ))} +
+
+ ); + } + + return null; +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/index.tsx b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx new file mode 100644 index 00000000..0cdfdbf3 --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/index.tsx @@ -0,0 +1,167 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { ActionButtons, AwaitingBadge, StatusBadge } from "./Badges"; +import { BatchRowMenu } from "./BatchRowMenu"; +import { SignerList } from "./SignerList"; +import { TxDetails } from "./TxDetails"; +import { TxHeader } from "./TxHeader"; +import { getTxTypeLabel } from "./utils"; +import { TxStatus, TxType } from "@polypay/shared"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { TransactionRowData, useTransactionVote, useWalletCommitments, useWalletThreshold } from "~~/hooks"; +import { formatAddress } from "~~/utils/format"; + +export { convertToRowData } from "./utils"; + +interface TransactionRowProps { + tx: TransactionRowData; + onSuccess?: () => void; +} + +export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { + const [expanded, setExpanded] = useState(false); + const [isExecutable, setIsExecutable] = useState(false); + + const { data: walletThreshold } = useWalletThreshold(); + const { data: commitmentsData } = useWalletCommitments(); + + const totalSigners = commitmentsData?.length || 0; + + const { + approve, + deny, + execute, + isLoading: loading, + loadingState, + loadingStep, + totalSteps, + } = useTransactionVote({ onSuccess }); + + const handleApprove = async () => { + await approve(tx); + }; + const handleDeny = async () => { + await deny(tx); + }; + const handleExecute = async (txId: number) => { + await execute(txId); + }; + + const initiator = tx.members.find(m => m.isInitiator); + const initiatorName = initiator + ? initiator.name || formatAddress(initiator.commitment, { start: 4, end: 4 }) + : "Unknown"; + const initiatorCommitment = initiator?.commitment || ""; + + useEffect(() => { + if (tx.status !== TxStatus.PENDING) { + setIsExecutable(false); + return; + } + setIsExecutable(tx.approveCount >= Number(walletThreshold)); + }, [tx.status, tx.approveCount, walletThreshold]); + + const renderRightSide = () => { + if (tx.status === TxStatus.EXECUTED || tx.status === TxStatus.FAILED) { + return ; + } + + if (isExecutable || tx.status === TxStatus.EXECUTING) { + return ( + handleExecute(tx.txId)} + loading={loading} + isExecutable={isExecutable && tx.status !== TxStatus.EXECUTING} + isExecuting={tx.status === TxStatus.EXECUTING} + /> + ); + } + + if (tx.myVoteStatus === null) { + if (expanded) return ; + return ( + handleExecute(tx.txId)} + loading={loading} + isExecutable={false} + isExecuting={false} + /> + ); + } + + return ; + }; + + return ( +
+ {loading && loadingState && ( +
+
+ {loadingStep > 0 && totalSteps > 1 && ( + + Step {loadingStep}/{totalSteps} + + )} + {loadingState} +
+
+ )} + +
setExpanded(!expanded)} + > +
+
+ {expanded ? ( + + ) : ( + + )} + {getTxTypeLabel(tx.type)} + {!expanded && } +
+
e.stopPropagation()}> + {renderRightSide()} + {tx.type === TxType.BATCH && tx.batchData && } +
+
+ + {expanded && ( +
+ + item?.toString()) || []} + votedCount={tx.votedCount} + threshold={ + tx.status === TxStatus.EXECUTED || tx.status === TxStatus.FAILED + ? tx.approveCount + : Number(walletThreshold) || 0 + } + totalSigners={totalSigners} + myCommitment={tx.members.find(m => m.isMe)?.commitment || ""} + initiatorCommitment={initiatorCommitment} + txStatus={tx.status} + /> +
+ )} +
+
+ ); +} diff --git a/packages/nextjs/components/Dashboard/TransactionRow/utils.ts b/packages/nextjs/components/Dashboard/TransactionRow/utils.ts new file mode 100644 index 00000000..b2063d34 --- /dev/null +++ b/packages/nextjs/components/Dashboard/TransactionRow/utils.ts @@ -0,0 +1,90 @@ +import { Transaction, TxType, VoteType } from "@polypay/shared"; +import { BatchTransfer, Member, TransactionRowData, VoteStatus } from "~~/hooks"; + +export function convertToRowData(tx: Transaction, myCommitment: string): TransactionRowData { + const members: Member[] = tx.votes.map(vote => ({ + commitment: vote.voterCommitment, + name: vote.voterName || null, + isInitiator: vote.voterCommitment === tx.createdBy, + isMe: vote.voterCommitment === myCommitment, + voteStatus: vote.voteType === "APPROVE" ? "approved" : "denied", + })); + const myVote = tx.votes.find(v => v.voterCommitment === myCommitment); + const myVoteStatus: VoteStatus | null = myVote + ? myVote.voteType === VoteType.APPROVE + ? "approved" + : "denied" + : null; + + const approveCount = tx.votes.filter(v => v.voteType === VoteType.APPROVE).length; + + let batchData: BatchTransfer[] | undefined; + if (tx.batchData) { + try { + batchData = typeof tx.batchData === "string" ? JSON.parse(tx.batchData) : tx.batchData; + } catch { + batchData = undefined; + } + } + + return { + id: tx.id, + txId: tx.txId, + type: tx.type, + status: tx.status, + nonce: tx.nonce, + txHash: tx.txHash || undefined, + amount: tx.value || undefined, + recipientAddress: tx.to || undefined, + tokenAddress: tx.tokenAddress || undefined, + signerData: tx.signerData || null, + oldThreshold: tx.threshold, + newThreshold: tx.newThreshold || undefined, + batchData, + members, + votedCount: tx.votes.length, + threshold: tx.threshold, + approveCount, + myVoteStatus, + accountAddress: tx.accountAddress, + contact: tx.contact + ? { + id: tx.contact.id, + name: tx.contact.name, + address: tx.contact.address, + } + : undefined, + }; +} + +export function getTxTypeLabel(type: TxType): string { + switch (type) { + case TxType.TRANSFER: + return "Transfer"; + case TxType.ADD_SIGNER: + return "Add Signer"; + case TxType.REMOVE_SIGNER: + return "Remove Signer"; + case TxType.SET_THRESHOLD: + return "Threshold"; + case TxType.BATCH: + return "Batch"; + } +} + +export function getExpandedHeaderText(type: TxType): string { + switch (type) { + case TxType.ADD_SIGNER: + return "Added by"; + case TxType.REMOVE_SIGNER: + return "Removed by"; + case TxType.SET_THRESHOLD: + return "Updated by"; + case TxType.TRANSFER: + return "Created by"; + case TxType.BATCH: + return ""; + default: + return "Created by"; + } +} diff --git a/packages/nextjs/components/Sidebar/AccountItem.tsx b/packages/nextjs/components/Sidebar/AccountItem.tsx index cf310434..d859cbb7 100644 --- a/packages/nextjs/components/Sidebar/AccountItem.tsx +++ b/packages/nextjs/components/Sidebar/AccountItem.tsx @@ -9,6 +9,7 @@ import { useModalApp } from "~~/hooks"; import { useUpdateAccount } from "~~/hooks/api/useAccount"; import { useTokenPrices } from "~~/hooks/api/usePrice"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; +import { usePortfolioValue } from "~~/hooks/app/usePortfolioValue"; import { useTokenBalances } from "~~/hooks/app/useTokenBalance"; import { useAccountStore } from "~~/services/store"; import { getAccountAvatar } from "~~/utils/avatar"; @@ -50,14 +51,7 @@ export default function AccountItem({ const isLoading = isLoadingBalances || isLoadingPrices; - // Calculate total USD value - const totalUsdValue = React.useMemo(() => { - return tokens.reduce((sum, token) => { - const balance = balances[token.address] || "0"; - const price = getPriceBySymbol(token.symbol); - return sum + parseFloat(balance) * price; - }, 0); - }, [balances, getPriceBySymbol, tokens]); + const { totalUsdValue } = usePortfolioValue(tokens, balances, getPriceBySymbol); const formattedTotalUsd = totalUsdValue.toLocaleString("en-US", { minimumFractionDigits: 0, diff --git a/packages/nextjs/components/contact-book/ContactPicker.tsx b/packages/nextjs/components/contact-book/ContactPicker.tsx index 676dc59b..f6f9ccb4 100644 --- a/packages/nextjs/components/contact-book/ContactPicker.tsx +++ b/packages/nextjs/components/contact-book/ContactPicker.tsx @@ -5,6 +5,7 @@ import { Contact } from "@polypay/shared"; import { Search, X } from "lucide-react"; import { useContacts, useGroups } from "~~/hooks"; import { useClickOutside } from "~~/hooks/useClickOutside"; +import { formatAddress } from "~~/utils/format"; interface ContactPickerProps { accountId: string | null; @@ -51,10 +52,6 @@ export function ContactPicker({ accountId, onSelect, disabled }: ContactPickerPr setSearchTerm(""); }; - const formatAddress = (address: string) => { - return `${address.slice(0, 6)}...${address.slice(-4)}`; - }; - if (!accountId) return null; return ( diff --git a/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx index 001effc1..283046cd 100644 --- a/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx +++ b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Image from "next/image"; import { Contact, ContactGroup, CreateBatchItemDto, ZERO_ADDRESS } from "@polypay/shared"; -import { ArrowLeft, GripVertical, X } from "lucide-react"; +import { ArrowLeft, GripVertical, Upload, X } from "lucide-react"; import { parseUnits } from "viem"; import { Checkbox } from "~~/components/Common"; import ModalContainer from "~~/components/modals/ModalContainer"; @@ -12,18 +12,22 @@ import { useContacts, useCreateBatchItem, useGroups } from "~~/hooks"; import { useBatchTransaction } from "~~/hooks"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; import { formatAddress } from "~~/utils/format"; +import { parseBatchCsv } from "~~/utils/parseBatchCsv"; import { notification } from "~~/utils/scaffold-eth"; -interface BatchContactEntry { +export interface BatchContactEntry { contact: Contact; amount: string; tokenAddress: string; + isSynthetic?: boolean; } interface CreateBatchFromContactsModalProps { isOpen: boolean; onClose: () => void; accountId?: string; + mode?: "manual" | "csv"; + initialBatchItems?: BatchContactEntry[]; [key: string]: any; } @@ -33,16 +37,19 @@ export default function CreateBatchFromContactsModal({ isOpen, onClose, accountId, + mode = "manual", + initialBatchItems, }: CreateBatchFromContactsModalProps) { const [step, setStep] = useState(1); const [selectedContactIds, setSelectedContactIds] = useState>(new Set()); const [selectedGroupId, setSelectedGroupId] = useState(null); const [batchEntries, setBatchEntries] = useState([]); + const hasAppliedInitialItems = useRef(false); const { data: contacts = [] } = useContacts(accountId || null, selectedGroupId || undefined); const { data: allContacts = [] } = useContacts(accountId || null); const { data: groups = [] } = useGroups(accountId || null); - const { tokens, nativeEth } = useNetworkTokens(); + const { tokens, nativeEth, chainId } = useNetworkTokens(); const { mutateAsync: createBatchItem } = useCreateBatchItem(); const { proposeBatch, @@ -52,7 +59,6 @@ export default function CreateBatchFromContactsModal({ totalSteps, } = useBatchTransaction({ onSuccess: () => { - notification.success("Batch transaction created!"); handleReset(); onClose(); }, @@ -60,11 +66,21 @@ export default function CreateBatchFromContactsModal({ const defaultToken = nativeEth || tokens[0]; + // Skip to step 2 when initialBatchItems is provided (duplicate flow) + useEffect(() => { + if (isOpen && initialBatchItems && initialBatchItems.length > 0 && !hasAppliedInitialItems.current) { + setBatchEntries(initialBatchItems); + setStep(2); + hasAppliedInitialItems.current = true; + } + }, [isOpen, initialBatchItems]); + const handleReset = () => { setStep(1); setSelectedContactIds(new Set()); setSelectedGroupId(null); setBatchEntries([]); + hasAppliedInitialItems.current = false; }; const handleClose = () => { @@ -108,6 +124,54 @@ export default function CreateBatchFromContactsModal({ setStep(2); }; + // CSV upload handler + const handleCsvUpload = (file: File) => { + const reader = new FileReader(); + reader.onload = e => { + const text = e.target?.result as string; + if (!text) return; + + const { validEntries, invalidCount } = parseBatchCsv(text, chainId); + + if (invalidCount > 0) { + notification.error(`${invalidCount} row(s) skipped (invalid address, amount, or token)`); + } + + if (validEntries.length === 0) { + notification.error("No valid rows found"); + return; + } + + const entries: BatchContactEntry[] = validEntries.map(entry => ({ + contact: { + id: crypto.randomUUID(), + name: formatAddress(entry.address, { start: 6, end: 4 }), + address: entry.address, + accountId: "", + groups: [], + createdAt: "", + updatedAt: "", + } as Contact, + amount: entry.amount, + tokenAddress: entry.tokenAddress, + isSynthetic: true, + })); + + setBatchEntries(entries); + setStep(2); + }; + reader.readAsText(file); + }; + + // Back button logic + const handleBack = () => { + if (step === 2 && initialBatchItems && initialBatchItems.length > 0) { + handleClose(); + return; + } + setStep((step - 1) as Step); + }; + // Step 2: Amount & Token const updateEntryAmount = (index: number, amount: string) => { setBatchEntries(prev => prev.map((entry, i) => (i === index ? { ...entry, amount } : entry))); @@ -143,7 +207,7 @@ export default function CreateBatchFromContactsModal({ recipient: entry.contact.address, amount: amountInSmallestUnit, tokenAddress: entry.tokenAddress === ZERO_ADDRESS ? undefined : entry.tokenAddress, - contactId: entry.contact.id, + contactId: entry.isSynthetic ? undefined : entry.contact.id, }; return createBatchItem(dto); }), @@ -154,118 +218,194 @@ export default function CreateBatchFromContactsModal({ } }; - const title = step === 1 ? "Choose contact" : step === 2 ? "Add to batch" : "Transactions summary"; + const isCsvMode = mode === "csv"; + const title = + step === 1 ? (isCsvMode ? "Upload CSV" : "Choose contact") : step === 2 ? "Add to batch" : "Transactions summary"; return ( - {/* Header */} -
- {step > 1 ? ( - + ) : ( +
+ )} +

{title}

+ - ) : ( -
- )} -

{title}

- -
+
- {/* Content */} -
- {step === 1 && ( - - )} - - {step === 2 && ( - - )} - - {step === 3 && } -
+ {/* Content */} +
+ {step === 1 && + (isCsvMode ? ( + + ) : ( + + ))} - {/* Footer */} -
- -
- {step === 1 && ( - - )} {step === 2 && ( - + )} - {step === 3 && ( -
- {isProposing && loadingState && loadingStep > 0 && ( -
-
- Step {loadingStep} of {totalSteps} — {loadingState} -
-
-
-
-
- )} + + {step === 3 && } +
+ + {/* Footer */} +
+ +
+ {step === 1 && !isCsvMode && ( -
- )} + )} + {step === 2 && ( + + )} + {step === 3 && ( +
+ {isProposing && loadingState && loadingStep > 0 && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )} + +
+ )} +
); } -// --- Step 1: Choose Contact --- +// --- Step 1a: Upload CSV --- +function StepUploadCsv({ onUpload }: { onUpload: (file: File) => void }) { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleFile = (file: File) => { + if (!file.name.endsWith(".csv")) { + notification.error("Please upload a CSV file"); + return; + } + onUpload(file); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + return ( +
+
setIsDragging(false)} + onClick={() => fileInputRef.current?.click()} + className={`flex flex-col items-center justify-center gap-3 border-2 border-dashed rounded-xl p-10 cursor-pointer transition-colors ${ + isDragging ? "border-main-pink bg-pink-50" : "border-grey-300 hover:border-grey-400" + }`} + > + +

+ Drag & drop a CSV file here, or browse +

+ { + const file = e.target.files?.[0]; + if (file) handleFile(file); + e.target.value = ""; + }} + /> +
+ +
+

Expected CSV format:

+ + address,amount,token +
+ 0x1234...abcd,1.5,ETH +
+ 0x5678...efgh,50.5,USDC +
+ 0x5678...ef12,4.8,ZEN +
+
+
+ ); +} + +// --- Step 1b: Choose Contact --- function StepChooseContact({ contacts, groups, diff --git a/packages/nextjs/components/modals/CreateGroupModal.tsx b/packages/nextjs/components/modals/CreateGroupModal.tsx index d7df3b13..e299a1f8 100644 --- a/packages/nextjs/components/modals/CreateGroupModal.tsx +++ b/packages/nextjs/components/modals/CreateGroupModal.tsx @@ -8,6 +8,7 @@ import { useCreateGroup } from "~~/hooks"; import { useZodForm } from "~~/hooks/form"; import { createGroupSchema } from "~~/lib/form/schemas"; import { ModalProps } from "~~/types/modal"; +import { formatAddress } from "~~/utils/format"; interface CreateGroupModalProps extends ModalProps { onSuccess?: () => void; @@ -25,9 +26,9 @@ const avatarColors = [ const getAvatarColor = (index: number): string => avatarColors[index % avatarColors.length]; -const formatAddress = (address: string): string => { +const formatGroupAddress = (address: string): string => { if (!address) return ""; - return `[${address.slice(0, 4)}...${address.slice(-4)}]`; + return `[${formatAddress(address, { start: 4, end: 4 })}]`; }; const getContactGroups = (contact: Contact): string => { @@ -169,7 +170,7 @@ const CreateGroupModal: React.FC = ({
- {formatAddress(contact.address)} + {formatGroupAddress(contact.address)}
); diff --git a/packages/nextjs/components/modals/PortFolioModal.tsx b/packages/nextjs/components/modals/PortFolioModal.tsx index f83d1741..7e1bfda1 100644 --- a/packages/nextjs/components/modals/PortFolioModal.tsx +++ b/packages/nextjs/components/modals/PortFolioModal.tsx @@ -12,6 +12,7 @@ import { useMetaMultiSigWallet } from "~~/hooks"; import { useTokenPrices } from "~~/hooks/api/usePrice"; import { useModalApp } from "~~/hooks/app/useModalApp"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; +import { usePortfolioValue } from "~~/hooks/app/usePortfolioValue"; import { useAppRouter } from "~~/hooks/app/useRouteApp"; import { useTokenBalances } from "~~/hooks/app/useTokenBalance"; import { useAccountStore } from "~~/services/store"; @@ -77,26 +78,7 @@ export const PortfolioModal: React.FC = ({ children }) => { const isLoading = isLoadingBalances || isLoadingPrices; - // Calculate total portfolio USD value - const totalUsdValue = React.useMemo(() => { - return tokens.reduce((sum, token) => { - const balance = balances[token.address] || "0"; - const price = getPriceBySymbol(token.symbol); - return sum + parseFloat(balance) * price; - }, 0); - }, [balances, getPriceBySymbol, tokens]); - - // Get USD value for a specific token - const getTokenUsdValue = (token: ResolvedToken): number => { - const balance = balances[token.address] || "0"; - const price = getPriceBySymbol(token.symbol); - return parseFloat(balance) * price; - }; - - // Get balance for a specific token - const getTokenBalance = (token: ResolvedToken): string => { - return balances[token.address] || "0"; - }; + const { totalUsdValue, getTokenUsdValue, getTokenBalance } = usePortfolioValue(tokens, balances, getPriceBySymbol); const toggleShowBalance = () => { setShowBalance(!showBalance); diff --git a/packages/nextjs/components/modals/RemoveSignerModal.tsx b/packages/nextjs/components/modals/RemoveSignerModal.tsx index 3921066e..deca49b1 100644 --- a/packages/nextjs/components/modals/RemoveSignerModal.tsx +++ b/packages/nextjs/components/modals/RemoveSignerModal.tsx @@ -5,6 +5,7 @@ import Image from "next/image"; import ModalContainer from "./ModalContainer"; import { X } from "lucide-react"; import { ModalProps } from "~~/types/modal"; +import { formatCommitment } from "~~/utils/format"; interface RemoveSignerModalProps extends ModalProps { signer?: { @@ -15,11 +16,6 @@ interface RemoveSignerModalProps extends ModalProps { } const RemoveSignerModal: React.FC = ({ isOpen, onClose, signer, onRemove }) => { - const formatCommitment = (commitment: string) => { - if (!commitment) return ""; - return `${commitment.slice(0, 4)}...${commitment.slice(-3)}`; - }; - const handleRemove = () => { onRemove?.(); onClose(); diff --git a/packages/nextjs/hooks/app/transaction/transactionSteps.ts b/packages/nextjs/hooks/app/transaction/transactionSteps.ts new file mode 100644 index 00000000..00992e03 --- /dev/null +++ b/packages/nextjs/hooks/app/transaction/transactionSteps.ts @@ -0,0 +1,17 @@ +export type TransactionStepAction = "transfer" | "batch" | "proposal" | "approval"; + +export function createTransactionSteps(action: TransactionStepAction) { + const labels: Record = { + transfer: "Preparing your transfer...", + batch: "Preparing your batch...", + proposal: "Preparing your proposal...", + approval: "Preparing approval...", + }; + + return [ + { id: 1, label: labels[action] }, + { id: 2, label: "Waiting for wallet approval..." }, + { id: 3, label: "Securing your transaction..." }, + { id: 4, label: "Almost done, submitting..." }, + ]; +} diff --git a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts index e31faa58..c359911e 100644 --- a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts @@ -1,4 +1,5 @@ import { useState } from "react"; +import { createTransactionSteps } from "./transactionSteps"; import { BatchItem, TxType, ZERO_ADDRESS, encodeBatchTransfer, encodeBatchTransferMulti } from "@polypay/shared"; import { useWalletClient } from "wagmi"; import { useMetaMultiSigWallet } from "~~/hooks"; @@ -13,16 +14,10 @@ interface UseBatchTransactionOptions { onSuccess?: () => void; } -const BATCH_STEPS = [ - { id: 1, label: "Preparing your batch..." }, - { id: 2, label: "Waiting for wallet approval..." }, - { id: 3, label: "Securing your transaction..." }, - { id: 4, label: "Almost done, submitting..." }, -]; - export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { - const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = - useStepLoading(BATCH_STEPS); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = useStepLoading( + createTransactionSteps("batch"), + ); const { data: walletClient } = useWalletClient(); const { secret, commitment: myCommitment } = useIdentityStore(); @@ -58,7 +53,6 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { const { nonce } = await reserveNonce(metaMultiSigWallet.address); // 2. Get current threshold and commitments - startStep(1); const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); // 3. Prepare batch data diff --git a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts index 7d917c47..7da7dabb 100644 --- a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts @@ -1,4 +1,5 @@ import { useState } from "react"; +import { createTransactionSteps } from "./transactionSteps"; import { SignerData, TxType, encodeAddSigners, encodeRemoveSigners, encodeUpdateThreshold } from "@polypay/shared"; import { useWalletClient } from "wagmi"; import { useGenerateProof, useMetaMultiSigWallet, useWalletCommitments, useWalletThreshold } from "~~/hooks"; @@ -11,16 +12,10 @@ interface UseSignerTransactionOptions { onSuccess?: () => void; } -const SIGNER_STEPS = [ - { id: 1, label: "Preparing your proposal..." }, - { id: 2, label: "Waiting for wallet approval..." }, - { id: 3, label: "Securing your transaction..." }, - { id: 4, label: "Almost done, submitting..." }, -]; - export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { - const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = - useStepLoading(SIGNER_STEPS); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = useStepLoading( + createTransactionSteps("proposal"), + ); const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); diff --git a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts index 404688a6..529cc406 100644 --- a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts +++ b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts @@ -1,4 +1,5 @@ import { useState } from "react"; +import { createTransactionSteps } from "./transactionSteps"; import { SignerData, TxStatus, @@ -118,16 +119,9 @@ function buildTransactionParams(tx: TransactionRowData): { return { to, value, callData }; } -const APPROVE_STEPS = [ - { id: 1, label: "Preparing approval..." }, - { id: 2, label: "Waiting for wallet approval..." }, - { id: 3, label: "Securing your transaction..." }, - { id: 4, label: "Almost done, submitting..." }, -]; - export const useTransactionVote = (options?: UseTransactionVoteOptions) => { const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset, startLoading } = - useStepLoading(APPROVE_STEPS); + useStepLoading(createTransactionSteps("approval")); const { commitment } = useIdentityStore(); const { data: walletClient } = useWalletClient(); diff --git a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts index b41e2762..c7f900b5 100644 --- a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { createTransactionSteps } from "./transactionSteps"; import { ResolvedToken, TxType, ZERO_ADDRESS, encodeERC20Transfer, parseTokenAmount } from "@polypay/shared"; import { parseEther } from "viem"; import { useWalletClient } from "wagmi"; @@ -20,16 +21,10 @@ interface UseTransferTransactionOptions { onSuccess?: () => void; } -const TRANSFER_STEPS = [ - { id: 1, label: "Preparing your transfer..." }, - { id: 2, label: "Waiting for wallet approval..." }, - { id: 3, label: "Securing your transaction..." }, - { id: 4, label: "Almost done, submitting..." }, -]; - export const useTransferTransaction = (options?: UseTransferTransactionOptions) => { - const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = - useStepLoading(TRANSFER_STEPS); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = useStepLoading( + createTransactionSteps("transfer"), + ); const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); @@ -52,7 +47,6 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) const { nonce } = await reserveNonce(metaMultiSigWallet.address); // 2. Get current threshold and commitments - startStep(1); const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); // 3. Parse amount based on token type diff --git a/packages/nextjs/hooks/app/usePortfolioValue.ts b/packages/nextjs/hooks/app/usePortfolioValue.ts new file mode 100644 index 00000000..7d71eda9 --- /dev/null +++ b/packages/nextjs/hooks/app/usePortfolioValue.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { ResolvedToken } from "@polypay/shared"; + +export function usePortfolioValue( + tokens: ResolvedToken[], + balances: Record, + getPriceBySymbol: (symbol: string) => number, +) { + const totalUsdValue = useMemo(() => { + return tokens.reduce((sum, token) => { + const balance = balances[token.address] || "0"; + const price = getPriceBySymbol(token.symbol); + return sum + parseFloat(balance) * price; + }, 0); + }, [balances, getPriceBySymbol, tokens]); + + const getTokenUsdValue = (token: ResolvedToken): number => { + const balance = balances[token.address] || "0"; + const price = getPriceBySymbol(token.symbol); + return parseFloat(balance) * price; + }; + + const getTokenBalance = (token: ResolvedToken): string => { + return balances[token.address] || "0"; + }; + + return { totalUsdValue, getTokenUsdValue, getTokenBalance }; +} diff --git a/packages/nextjs/utils/format.ts b/packages/nextjs/utils/format.ts index ebba9d1a..0dce3e60 100644 --- a/packages/nextjs/utils/format.ts +++ b/packages/nextjs/utils/format.ts @@ -33,3 +33,20 @@ export function formatAddress(address: string, options?: { start?: number; end?: if (address.length <= minLength) return address; return `${address.slice(0, start)}...${address.slice(-end)}`; } + +/** + * Format commitment to short form + * @param commitment - Full commitment string + * @param options - Slice options { start: number, end: number } + * @returns Shortened commitment like "1234...3512" + */ +export function formatCommitment(commitment: string, options?: { start?: number; end?: number }): string { + if (!commitment) return ""; + + const start = options?.start ?? 4; + const end = options?.end ?? 3; + const minLength = start + end; + + if (commitment.length <= minLength) return commitment; + return `${commitment.slice(0, start)}...${commitment.slice(-end)}`; +} diff --git a/packages/nextjs/utils/parseBatchCsv.ts b/packages/nextjs/utils/parseBatchCsv.ts new file mode 100644 index 00000000..9f38c4d7 --- /dev/null +++ b/packages/nextjs/utils/parseBatchCsv.ts @@ -0,0 +1,68 @@ +import { getTokenBySymbol } from "@polypay/shared"; +import { isAddress } from "viem"; + +interface ParsedBatchEntry { + address: string; + amount: string; + tokenAddress: string; +} + +interface ParseBatchCsvResult { + validEntries: ParsedBatchEntry[]; + invalidCount: number; +} + +const HEADER_PATTERN = /^address,amount,token$/i; + +export function parseBatchCsv(csvText: string, chainId: number): ParseBatchCsvResult { + // Strip BOM + const cleaned = csvText.replace(/^\uFEFF/, ""); + const lines = cleaned.split(/\r\n|\r|\n/).filter(line => line.trim() !== ""); + + if (lines.length === 0) { + return { validEntries: [], invalidCount: 0 }; + } + + let startIndex = 0; + if (HEADER_PATTERN.test(lines[0].trim())) { + startIndex = 1; + } + + const validEntries: ParsedBatchEntry[] = []; + let invalidCount = 0; + + for (let i = startIndex; i < lines.length; i++) { + const parts = lines[i].split(",").map(p => p.trim()); + if (parts.length < 3) { + invalidCount++; + continue; + } + + const [address, amountStr, symbol] = parts; + + if (!isAddress(address)) { + invalidCount++; + continue; + } + + const amount = parseFloat(amountStr); + if (!Number.isFinite(amount) || amount <= 0) { + invalidCount++; + continue; + } + + try { + const token = getTokenBySymbol(symbol, chainId); + // Check if the resolved token matches the requested symbol (getTokenBySymbol falls back to native) + if (token.symbol.toLowerCase() !== symbol.toLowerCase()) { + invalidCount++; + continue; + } + validEntries.push({ address, amount: amountStr, tokenAddress: token.address }); + } catch { + invalidCount++; + } + } + + return { validEntries, invalidCount }; +} diff --git a/packages/shared/src/dto/account/create-account.dto.ts b/packages/shared/src/dto/account/create-account.dto.ts index b57b14ae..67cf0218 100644 --- a/packages/shared/src/dto/account/create-account.dto.ts +++ b/packages/shared/src/dto/account/create-account.dto.ts @@ -1,10 +1,12 @@ import { + ArrayMaxSize, ArrayMinSize, IsArray, IsNotEmpty, IsNumber, IsOptional, IsString, + MaxLength, Min, } from "class-validator"; import { SignerData } from "../../types/index"; @@ -12,6 +14,7 @@ import { SignerData } from "../../types/index"; export class CreateAccountDto { @IsNotEmpty() @IsString() + @MaxLength(100) name: string; @IsNotEmpty() @@ -21,6 +24,7 @@ export class CreateAccountDto { @IsArray() @ArrayMinSize(1) + @ArrayMaxSize(10) signers: SignerData[]; @IsNotEmpty() @@ -29,12 +33,14 @@ export class CreateAccountDto { @IsOptional() @IsString() + @MaxLength(42) userAddress?: string; } export class CreateAccountBatchDto { @IsNotEmpty() @IsString() + @MaxLength(100) name: string; @IsNotEmpty() @@ -44,13 +50,16 @@ export class CreateAccountBatchDto { @IsArray() @ArrayMinSize(1) + @ArrayMaxSize(10) signers: SignerData[]; @IsArray() @ArrayMinSize(1) + @ArrayMaxSize(10) chainIds: number[]; @IsOptional() @IsString() + @MaxLength(42) userAddress?: string; } diff --git a/packages/shared/src/dto/auth/login.dto.ts b/packages/shared/src/dto/auth/login.dto.ts index ef7b0fd8..0eeec6b5 100644 --- a/packages/shared/src/dto/auth/login.dto.ts +++ b/packages/shared/src/dto/auth/login.dto.ts @@ -1,24 +1,36 @@ -import { IsNotEmpty, IsString, IsArray, IsOptional } from "class-validator"; +import { + ArrayMaxSize, + IsNotEmpty, + IsString, + IsArray, + IsOptional, + MaxLength, +} from "class-validator"; export class LoginDto { @IsNotEmpty() @IsString() + @MaxLength(256) commitment: string; @IsNotEmpty() @IsArray() + @ArrayMaxSize(65536) proof: number[]; @IsNotEmpty() @IsArray() + @ArrayMaxSize(256) @IsString({ each: true }) publicInputs: string[]; @IsOptional() @IsString() + @MaxLength(65536) vk?: string; @IsOptional() @IsString() + @MaxLength(42) walletAddress?: string; // For analytics only - NOT stored in database } diff --git a/packages/shared/src/dto/batch-item/create-batch-item.dto.ts b/packages/shared/src/dto/batch-item/create-batch-item.dto.ts index 9afa74c2..2ae81189 100644 --- a/packages/shared/src/dto/batch-item/create-batch-item.dto.ts +++ b/packages/shared/src/dto/batch-item/create-batch-item.dto.ts @@ -1,4 +1,10 @@ -import { IsNotEmpty, IsOptional, IsString, Matches } from "class-validator"; +import { + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from "class-validator"; export class CreateBatchItemDto { @IsNotEmpty() @@ -8,13 +14,16 @@ export class CreateBatchItemDto { @IsNotEmpty() @IsString() + @MaxLength(78) amount: string; @IsOptional() @IsString() + @Matches(/^0x[a-fA-F0-9]{40}$/, { message: "Invalid token address" }) tokenAddress?: string; @IsString() @IsOptional() + @MaxLength(256) contactId?: string; } diff --git a/packages/shared/src/dto/feature-request/create-feature-request.dto.ts b/packages/shared/src/dto/feature-request/create-feature-request.dto.ts index 3b422187..3da58063 100644 --- a/packages/shared/src/dto/feature-request/create-feature-request.dto.ts +++ b/packages/shared/src/dto/feature-request/create-feature-request.dto.ts @@ -1,7 +1,8 @@ -import { IsNotEmpty, IsString } from "class-validator"; +import { IsNotEmpty, IsString, MaxLength } from "class-validator"; export class CreateFeatureRequestDto { @IsNotEmpty() @IsString() + @MaxLength(5000) content: string; } diff --git a/packages/shared/src/dto/transaction/approve.dto.ts b/packages/shared/src/dto/transaction/approve.dto.ts index 162db99e..930dc596 100644 --- a/packages/shared/src/dto/transaction/approve.dto.ts +++ b/packages/shared/src/dto/transaction/approve.dto.ts @@ -1,24 +1,36 @@ -import { IsArray, IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; export class ApproveTransactionDto { @IsNotEmpty() @IsArray() + @ArrayMaxSize(65536) proof: number[]; @IsNotEmpty() @IsArray() + @ArrayMaxSize(256) @IsString({ each: true }) publicInputs: string[]; @IsNotEmpty() @IsString() + @MaxLength(256) nullifier: string; @IsOptional() @IsString() + @MaxLength(65536) vk?: string; @IsOptional() @IsString() + @MaxLength(42) userAddress?: string; } diff --git a/packages/shared/src/dto/transaction/create-transaction.dto.ts b/packages/shared/src/dto/transaction/create-transaction.dto.ts index bb2fb69f..ba6009c3 100644 --- a/packages/shared/src/dto/transaction/create-transaction.dto.ts +++ b/packages/shared/src/dto/transaction/create-transaction.dto.ts @@ -7,11 +7,15 @@ import { IsNumber, IsOptional, IsString, + Matches, + MaxLength, Min, } from "class-validator"; import { TxType } from "../../enums/index"; import { SignerData } from "../../types/index"; +const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; + export class CreateTransactionDto { @IsNotEmpty() @IsEnum(TxType) @@ -24,6 +28,7 @@ export class CreateTransactionDto { @IsNotEmpty() @IsString() + @Matches(ETH_ADDRESS_REGEX, { message: "Invalid account address" }) accountAddress: string; @IsNotEmpty() @@ -34,19 +39,23 @@ export class CreateTransactionDto { // TRANSFER @IsOptional() @IsString() + @Matches(ETH_ADDRESS_REGEX, { message: "Invalid recipient address" }) to?: string; @IsOptional() @IsString() + @MaxLength(78) value?: string; @IsOptional() @IsString() + @Matches(ETH_ADDRESS_REGEX, { message: "Invalid token address" }) tokenAddress?: string; // Link to contact (optional) @IsOptional() @IsString() + @MaxLength(256) contactId?: string; // ADD_SIGNER / REMOVE_SIGNER @@ -65,28 +74,34 @@ export class CreateTransactionDto { // BATCH @IsOptional() @IsArray() + @ArrayMaxSize(50) @IsString({ each: true }) batchItemIds?: string[]; // Proof data @IsNotEmpty() @IsArray() + @ArrayMaxSize(65536) proof: number[]; @IsNotEmpty() @IsArray() + @ArrayMaxSize(256) @IsString({ each: true }) publicInputs: string[]; @IsNotEmpty() @IsString() + @MaxLength(256) nullifier: string; @IsOptional() @IsString() + @MaxLength(65536) vk?: string; @IsOptional() @IsString() + @MaxLength(42) userAddress?: string; } diff --git a/packages/shared/src/dto/user/create-user.dto.ts b/packages/shared/src/dto/user/create-user.dto.ts index d1a00139..2a5dafc2 100644 --- a/packages/shared/src/dto/user/create-user.dto.ts +++ b/packages/shared/src/dto/user/create-user.dto.ts @@ -3,6 +3,7 @@ import { IsNotEmpty, IsOptional, IsString, MaxLength } from "class-validator"; export class CreateUserDto { @IsNotEmpty() @IsString() + @MaxLength(256) commitment: string; @IsOptional() diff --git a/yarn.lock b/yarn.lock index 3cfdd74f..5970a49a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3980,6 +3980,17 @@ __metadata: languageName: node linkType: hard +"@nestjs/throttler@npm:^6.5.0": + version: 6.5.0 + resolution: "@nestjs/throttler@npm:6.5.0" + peerDependencies: + "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + "@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + checksum: 60310fbf4f2d928c87e94fda77be5fc16a6e40d04dadb61ad856ff255fef3fdc35073b88e7ce81872e309ca14e984b719a944063541505d125b70f6a1f967289 + languageName: node + linkType: hard + "@nestjs/websockets@npm:^11.1.10": version: 11.1.12 resolution: "@nestjs/websockets@npm:11.1.12" @@ -4910,6 +4921,7 @@ __metadata: "@nestjs/swagger": ^8.0.7 "@nestjs/terminus": ^11.0.0 "@nestjs/testing": ^11.0.1 + "@nestjs/throttler": ^6.5.0 "@nestjs/websockets": ^11.1.10 "@noir-lang/noir_js": 1.0.0-beta.6 "@polypay/shared": 1.0.0