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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "accounts" ADD COLUMN "chain_id" INTEGER NOT NULL DEFAULT 26514;

-- AlterTable
ALTER TABLE "votes" ALTER COLUMN "domain_id" DROP DEFAULT;
3 changes: 2 additions & 1 deletion packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ model Account {
address String @unique
name String
threshold Int
chainId Int @default(26514) @map("chain_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
signers AccountSigner[]
Expand Down Expand Up @@ -107,7 +108,7 @@ model Vote {
zkVerifyTxHash String? @map("zkverify_tx_hash")

aggregationId String? @map("aggregation_id")
domainId Int? @default(175) @map("domain_id")
domainId Int? @map("domain_id")
merkleProof String[] @map("merkle_proof")
leafCount Int? @map("leaf_count")
leafIndex Int? @map("leaf_index")
Expand Down
35 changes: 34 additions & 1 deletion packages/backend/src/account/account.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
Patch,
UseGuards,
} from '@nestjs/common';
import { CreateAccountDto, UpdateAccountDto } from '@polypay/shared';
import {
CreateAccountDto,
CreateAccountBatchDto,
UpdateAccountDto,
} from '@polypay/shared';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@/auth/decorators/current-user.decorator';
import { AccountMemberGuard } from '@/auth/guards/account-member.guard';
Expand Down Expand Up @@ -71,6 +75,35 @@ export class AccountController {
return this.accountService.create(dto, user.commitment, dto.userAddress);
}

/**
* Batch create multisig accounts on multiple chains
* POST /api/accounts/batch
*/
@Post('batch')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Batch create multisig accounts on multiple chains',
description:
'Deploy multiple multisig accounts with the same configuration across different chains in a single request.',
})
@ApiBody({
type: CreateAccountBatchDto,
})
@ApiResponse({ status: 201, description: 'Accounts created successfully' })
@ApiResponse({ status: 400, description: 'Bad request - Invalid data' })
@ApiResponse({ status: 401, description: 'Unauthorized - Invalid token' })
async createBatch(
@CurrentUser() user: User,
@Body() dto: CreateAccountBatchDto,
) {
return this.accountService.createBatch(
dto,
user.commitment,
dto.userAddress,
);
}

/**
* Get multisig account by address
* GET /api/accounts/:address
Expand Down
178 changes: 176 additions & 2 deletions packages/backend/src/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '@/database/prisma.service';
import { CreateAccountDto, UpdateAccountDto } from '@polypay/shared';
import {
CreateAccountDto,
CreateAccountBatchDto,
UpdateAccountDto,
} from '@polypay/shared';
import { RelayerService } from '@/relayer-wallet/relayer-wallet.service';
import { EventsService } from '@/events/events.service';
import {
Expand Down Expand Up @@ -43,10 +47,11 @@ export class AccountService {
);
}

// 3. Deploy contract via relayer
// 3. Deploy contract via relayer on specified chain
const { address, txHash } = await this.relayerService.deployAccount(
commitments,
dto.threshold,
dto.chainId,
);

this.logger.log(`Account deployed at ${address}, txHash: ${txHash}`);
Expand Down Expand Up @@ -80,6 +85,7 @@ export class AccountService {
address,
name: dto.name,
threshold: dto.threshold,
chainId: dto.chainId,
},
});

Expand Down Expand Up @@ -142,6 +148,172 @@ export class AccountService {
return this.findByAddress(address);
}

async createBatch(
dto: CreateAccountBatchDto,
creatorCommitment: string,
userAddress?: string,
) {
// 1. Validate creator is in signers list
const commitments = dto.signers.map((s) => s.commitment);
if (!commitments.includes(creatorCommitment)) {
throw new BadRequestException('Creator must be in signers list');
}

// 2. Validate threshold
if (dto.threshold > dto.signers.length) {
throw new BadRequestException(
'Threshold cannot be greater than number of signers',
);
}

if (!dto.chainIds || dto.chainIds.length === 0) {
throw new BadRequestException('At least one chainId is required');
}

const uniqueChainIds = Array.from(new Set(dto.chainIds));
if (uniqueChainIds.length !== dto.chainIds.length) {
throw new BadRequestException('Duplicate chainIds are not allowed');
}

// 3. Deploy contracts via relayer for all chains
const deployments: { chainId: number; address: string; txHash: string }[] =
[];

for (const chainId of uniqueChainIds) {
try {
const { address, txHash } = await this.relayerService.deployAccount(
commitments,
dto.threshold,
chainId,
);

this.logger.log(
`Account deployed at ${address} on chain ${chainId}, txHash: ${txHash}`,
);

this.analyticsLogger.logCreateAccount(userAddress, address);

deployments.push({ chainId, address, txHash });
} catch (error) {
this.logger.error(
`Failed to deploy account on chain ${chainId}: ${error.message}`,
(error as Error).stack,
);
throw new BadRequestException(
'Failed to deploy account on one of the selected chains',
);
}
}

// 4. Persist all accounts and signers in a single transaction
const createdAccounts = await this.prisma.$transaction(async (prisma) => {
// Upsert all users (update name only if null)
const users = await Promise.all(
dto.signers.map(async (signer) => {
const existing = await prisma.user.findUnique({
where: { commitment: signer.commitment },
});

if (existing) {
return existing;
}

return prisma.user.create({
data: {
commitment: signer.commitment,
},
});
}),
);

const accounts = [];

for (const deployment of deployments) {
const newAccount = await prisma.account.create({
data: {
address: deployment.address,
name: dto.name,
threshold: dto.threshold,
chainId: deployment.chainId,
},
});

await Promise.all(
users.map((user) => {
const signerDto = dto.signers.find(
(s) => s.commitment === user.commitment,
);
return prisma.accountSigner.create({
data: {
userId: user.id,
accountId: newAccount.id,
isCreator: user.commitment === creatorCommitment,
displayName: signerDto?.name || null,
},
});
}),
);

const fullAccount = await prisma.account.findUniqueOrThrow({
where: { id: newAccount.id },
include: {
signers: {
include: {
user: true,
},
},
},
});

accounts.push(fullAccount);
}

return accounts;
});

// 5. Emit events for each account (excluding creator)
for (const account of createdAccounts) {
const eventData: AccountCreatedEventData = {
accountAddress: account.address,
name: account.name,
threshold: account.threshold,
signerCount: account.signers.length,
createdAt: account.createdAt.toISOString(),
};

const otherSigners = account.signers
.map((as) => as.user.commitment)
.filter((commitment) => commitment !== creatorCommitment);

if (otherSigners.length > 0) {
this.eventsService.emitToCommitments(
otherSigners,
ACCOUNT_CREATED_EVENT,
eventData,
);

this.logger.log(
`Emitted ${ACCOUNT_CREATED_EVENT} to ${otherSigners.length} other signers for account ${account.address}`,
);
}
}

// 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,
})),
}));
}

/**
* Get multisig account by address with signers
*/
Expand All @@ -166,6 +338,7 @@ export class AccountService {
address: account.address,
name: account.name,
threshold: account.threshold,
chainId: account.chainId,
createdAt: account.createdAt,
signers: account.signers.map((as) => ({
commitment: as.user.commitment,
Expand Down Expand Up @@ -195,6 +368,7 @@ export class AccountService {
address: account.address,
name: account.name,
threshold: account.threshold,
chainId: account.chainId,
createdAt: account.createdAt,
signers: account.signers.map((as) => ({
commitment: as.user.commitment,
Expand Down
8 changes: 6 additions & 2 deletions packages/backend/src/common/constants/proof.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export const DOMAIN_ID_HORIZEN_TESTNET = 175;
export const DOMAIN_ID_HORIZEN_MAINNET = 3;
export const DOMAIN_ID_BY_CHAIN_ID = {
2651420: 175, // Horizen testnet
84532: 2, // Base Sepolia
26514: 3, // Horizen mainnet
8453: 2, // Base mainnet
} as const;
16 changes: 8 additions & 8 deletions packages/backend/src/common/utils/proof.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NetworkValue } from '@polypay/shared';
import {
DOMAIN_ID_HORIZEN_MAINNET,
DOMAIN_ID_HORIZEN_TESTNET,
} from '../constants';
import { DOMAIN_ID_BY_CHAIN_ID } from '../constants';

export const getDomainId = (): number => {
const isMainnet = process.env.NETWORK === NetworkValue.mainnet;
return isMainnet ? DOMAIN_ID_HORIZEN_MAINNET : DOMAIN_ID_HORIZEN_TESTNET;
export const getDomainId = (chainId: number): number => {
const id =
DOMAIN_ID_BY_CHAIN_ID[chainId as keyof typeof DOMAIN_ID_BY_CHAIN_ID];
if (id === undefined) {
throw new Error(`Unsupported chainId for domainId: ${chainId}`);
}
return id;
};
Loading
Loading