Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions packages/backend/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"compilerOptions": {
"deleteOutDir": true,
"webpack": true,
"webpackConfigPath": "webpack.config.js",
"tsConfigPath": "tsconfig.json"
}
}
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 22 additions & 29 deletions packages/backend/src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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,
Expand All @@ -375,7 +368,7 @@ export class AccountService {
name: as.displayName,
isCreator: as.isCreator,
})),
}));
};
}

/**
Expand Down
175 changes: 88 additions & 87 deletions packages/backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,22 +27,9 @@ export class AdminService {
private readonly configService: ConfigService,
) {
const network = this.configService.get<string>('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;
}

/**
Expand Down Expand Up @@ -79,6 +68,26 @@ export class AdminService {
}
}

/**
* Build commitment → walletAddress map from loginHistory (batch query)
*/
private async buildCommitmentToAddressMap(
commitments: string[],
): Promise<Map<string, string>> {
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<string> {
const records: AnalyticsRecord[] = [];

Expand Down Expand Up @@ -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<number, typeof approveVotes> = {};
for (const vote of approveVotes) {
if (!votesByTx[vote.txId]) {
Expand All @@ -160,63 +199,38 @@ 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(),
);

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,
Expand All @@ -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',
});
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -40,6 +45,7 @@ import { ScheduleModule } from '@nestjs/schedule';
PartnerModule,
QuestModule,
RewardModule,
BalanceAlertModule,
ScheduleModule.forRoot(),
],
})
Expand Down
Loading
Loading