diff --git a/packages/backend/prisma/migrations/20260512000000_account_unique_per_chain/migration.sql b/packages/backend/prisma/migrations/20260512000000_account_unique_per_chain/migration.sql new file mode 100644 index 00000000..261aadb1 --- /dev/null +++ b/packages/backend/prisma/migrations/20260512000000_account_unique_per_chain/migration.sql @@ -0,0 +1,87 @@ +-- Make (address, chainId) the real uniqueness key for accounts, and propagate +-- chainId onto transactions so the foreign key targets the composite key. +-- +-- Root cause: contract addresses derive from CREATE = keccak256(rlp(sender, nonce)), +-- which does NOT include chainId. The same relayer wallet can therefore produce +-- identical addresses on Horizen and Base, which violated `accounts.address @unique` +-- and blocked multi-chain batch deployments. +-- +-- Migration order matters: backfill transactions.chain_id BEFORE swapping +-- constraints, otherwise the new composite FK has nothing to point at. +-- +-- Object naming convention follows the init migration: `@@unique` and `@unique` +-- are implemented as UNIQUE INDEXes (so DROP INDEX / CREATE UNIQUE INDEX), +-- while explicit @relation foreign keys are real constraints +-- (DROP CONSTRAINT / ADD CONSTRAINT FOREIGN KEY). + +-- 0. Pre-check: refuse to migrate if any transaction references a missing account. +-- A NULL chain_id after backfill would block the NOT NULL step downstream. +DO $$ +DECLARE orphan_count INTEGER; +BEGIN + SELECT COUNT(*) INTO orphan_count + FROM transactions t + LEFT JOIN accounts a ON a.address = t.account_address + WHERE a.address IS NULL; + + IF orphan_count > 0 THEN + RAISE EXCEPTION 'Found % orphan transactions with no matching account. Resolve manually before re-running.', orphan_count; + END IF; +END $$; + +-- 1. Add the new column as NULLABLE so the table accepts the change without a default. +ALTER TABLE "transactions" ADD COLUMN "chain_id" INTEGER; + +-- 2. Backfill from the parent account row. +UPDATE "transactions" t +SET "chain_id" = a."chain_id" +FROM "accounts" a +WHERE a."address" = t."account_address"; + +-- 3. Promote to NOT NULL once every row has a value. +ALTER TABLE "transactions" ALTER COLUMN "chain_id" SET NOT NULL; + +-- 4. Drop the old indexes / FK we're replacing. +-- UNIQUE INDEX → DROP INDEX, FK constraint → DROP CONSTRAINT. +ALTER TABLE "transactions" DROP CONSTRAINT "transactions_account_address_fkey"; +DROP INDEX "transactions_account_address_nonce_key"; +DROP INDEX "accounts_address_key"; + +-- 5. Replace with composite (address, chainId) uniqueness on accounts. +CREATE UNIQUE INDEX "accounts_address_chain_id_key" + ON "accounts" ("address", "chain_id"); + +-- 6. Replace transaction-side composite unique + FK so the relation targets (address, chainId). +CREATE UNIQUE INDEX "transactions_account_address_chain_id_nonce_key" + ON "transactions" ("account_address", "chain_id", "nonce"); + +ALTER TABLE "transactions" + ADD CONSTRAINT "transactions_account_address_chain_id_fkey" + FOREIGN KEY ("account_address", "chain_id") + REFERENCES "accounts" ("address", "chain_id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- 7. Swap the supporting helper index for the composite key. +DROP INDEX IF EXISTS "transactions_account_address_idx"; +CREATE INDEX "transactions_account_address_chain_id_idx" + ON "transactions" ("account_address", "chain_id"); + +-- 8. Same fix for reserved_nonces. Without chainId, two chains sharing an +-- address would also share a nonce-reservation row and could collide. +ALTER TABLE "reserved_nonces" ADD COLUMN "chain_id" INTEGER; + +UPDATE "reserved_nonces" r +SET "chain_id" = a."chain_id" +FROM "accounts" a +WHERE a."address" = r."account_address"; + +-- Any reservation pointing at an address with no matching account is stale — +-- safe to drop, the system will recreate as needed. +DELETE FROM "reserved_nonces" WHERE "chain_id" IS NULL; + +ALTER TABLE "reserved_nonces" ALTER COLUMN "chain_id" SET NOT NULL; + +DROP INDEX "reserved_nonces_account_address_nonce_key"; + +CREATE UNIQUE INDEX "reserved_nonces_account_address_chain_id_nonce_key" + ON "reserved_nonces" ("account_address", "chain_id", "nonce"); diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 4074d43c..8df45ddf 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -25,7 +25,7 @@ model User { model Account { id String @id @default(cuid()) - address String @unique + address String name String threshold Int chainId Int @default(26514) @map("chain_id") @@ -37,6 +37,11 @@ model Account { contacts Contact[] transactions Transaction[] + // Contract addresses derive from (relayer, nonce) without including chainId, + // so the same wallet can produce identical addresses on different chains + // (Horizen vs Base) when nonces align. The pair (address, chainId) is the + // real uniqueness key for a deployed multisig. + @@unique([address, chainId]) @@map("accounts") } @@ -64,6 +69,7 @@ model Transaction { value String? tokenAddress String? @map("token_address") accountAddress String @map("account_address") + chainId Int @map("chain_id") contactId String? @map("contact_id") signerData String? @map("signer_data") newThreshold Int? @map("new_threshold") @@ -74,13 +80,13 @@ model Transaction { executedAt DateTime? @map("executed_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - account Account @relation(fields: [accountAddress], references: [address]) + account Account @relation(fields: [accountAddress, chainId], references: [address, chainId]) contact Contact? @relation(fields: [contactId], references: [id]) votes Vote[] - @@unique([accountAddress, nonce]) + @@unique([accountAddress, chainId, nonce]) @@index([status]) - @@index([accountAddress]) + @@index([accountAddress, chainId]) @@index([contactId]) @@map("transactions") } @@ -88,11 +94,12 @@ model Transaction { model ReservedNonce { id String @id @default(cuid()) accountAddress String @map("account_address") + chainId Int @map("chain_id") nonce Int expiresAt DateTime @map("expires_at") createdAt DateTime @default(now()) @map("created_at") - @@unique([accountAddress, nonce]) + @@unique([accountAddress, chainId, nonce]) @@map("reserved_nonces") } diff --git a/packages/backend/src/account/account.controller.ts b/packages/backend/src/account/account.controller.ts index e57a7760..eceaabee 100644 --- a/packages/backend/src/account/account.controller.ts +++ b/packages/backend/src/account/account.controller.ts @@ -7,12 +7,14 @@ import { ApiBearerAuth, } from '@nestjs/swagger'; import { + BadRequestException, Controller, Get, Post, Body, Param, Patch, + Query, UseGuards, } from '@nestjs/common'; import { @@ -128,8 +130,12 @@ export class AccountController { description: 'Forbidden - Not an account signer', }) @ApiResponse({ status: 404, description: 'Account not found' }) - async findByAddress(@Param('address') address: string) { - return this.accountService.findByAddress(address); + async findByAddress( + @Param('address') address: string, + @Query('chainId') chainIdRaw: string, + ) { + const chainId = parseChainIdQuery(chainIdRaw); + return this.accountService.findByAddress(address, chainId); } /** @@ -170,8 +176,22 @@ export class AccountController { @ApiResponse({ status: 404, description: 'Account not found' }) async update( @Param('address') address: string, + @Query('chainId') chainIdRaw: string, @Body() dto: UpdateAccountDto, ) { - return this.accountService.update(address, dto); + const chainId = parseChainIdQuery(chainIdRaw); + return this.accountService.update(address, chainId, dto); + } +} + +// (address, chainId) is the real account key; refuse requests that omit it +// rather than silently picking the first match. +function parseChainIdQuery(value: string | undefined): number { + const chainId = Number.parseInt(value ?? '', 10); + if (!Number.isFinite(chainId) || chainId <= 0) { + throw new BadRequestException( + 'chainId query parameter is required (e.g. ?chainId=8453)', + ); } + return chainId; } diff --git a/packages/backend/src/account/account.service.ts b/packages/backend/src/account/account.service.ts index e516fed7..f65f9d36 100644 --- a/packages/backend/src/account/account.service.ts +++ b/packages/backend/src/account/account.service.ts @@ -148,7 +148,7 @@ export class AccountService { ); } - return this.findByAddress(address); + return this.findByAddress(address, dto.chainId); } async createBatch( @@ -309,11 +309,15 @@ export class AccountService { } /** - * Get multisig account by address with signers + * Get multisig account by (address, chainId) — the composite uniqueness key. + * The same address can exist on multiple chains (Horizen vs Base) when + * relayer nonces align, so chainId is mandatory to disambiguate. */ - async findByAddress(address: string) { + async findByAddress(address: string, chainId: number) { const account = await this.prisma.account.findUnique({ - where: { address }, + where: { + address_chainId: { address, chainId }, + }, include: { signers: { include: { @@ -383,9 +387,11 @@ export class AccountService { /** * Update multisig account by address */ - async update(address: string, dto: UpdateAccountDto) { + async update(address: string, chainId: number, dto: UpdateAccountDto) { const account = await this.prisma.account.findUnique({ - where: { address }, + where: { + address_chainId: { address, chainId }, + }, }); if (!account) { @@ -393,12 +399,12 @@ export class AccountService { } await this.prisma.account.update({ - where: { address }, + where: { id: account.id }, data: { name: dto.name, }, }); - return this.findByAddress(address); + return this.findByAddress(address, chainId); } } diff --git a/packages/backend/src/auth/guards/account-member.guard.ts b/packages/backend/src/auth/guards/account-member.guard.ts index 953283b8..3f161f4e 100644 --- a/packages/backend/src/auth/guards/account-member.guard.ts +++ b/packages/backend/src/auth/guards/account-member.guard.ts @@ -1,8 +1,9 @@ import { - Injectable, + BadRequestException, CanActivate, ExecutionContext, ForbiddenException, + Injectable, } from '@nestjs/common'; import { PrismaService } from '@/database/prisma.service'; import { NOT_MEMBER_OF_ACCOUNT } from '@/common/constants'; @@ -15,6 +16,7 @@ export class AccountMemberGuard implements CanActivate { const request = context.switchToHttp().getRequest(); const userCommitment = request.user?.commitment; const accountAddress = request.params?.address; + const chainIdRaw = request.query?.chainId; if (!userCommitment) { throw new ForbiddenException('User not authenticated'); @@ -24,10 +26,21 @@ export class AccountMemberGuard implements CanActivate { throw new ForbiddenException('Account address not provided'); } - // Check if user is a member of the wallet + // chainId is mandatory: the same address can exist on multiple chains + // (same relayer + same nonce on Horizen vs Base). Without scoping the + // lookup by chainId, a signer of one account could trick the guard into + // letting them act on a different account that happens to share its + // address. + const chainId = Number.parseInt(String(chainIdRaw ?? ''), 10); + if (!Number.isFinite(chainId) || chainId <= 0) { + throw new BadRequestException( + 'chainId query parameter is required (e.g. ?chainId=8453)', + ); + } + const membership = await this.prisma.accountSigner.findFirst({ where: { - account: { address: accountAddress }, + account: { address: accountAddress, chainId }, user: { commitment: userCommitment }, }, }); diff --git a/packages/backend/src/auth/guards/transaction-access.guard.ts b/packages/backend/src/auth/guards/transaction-access.guard.ts index 7a25dec3..3bb3b06f 100644 --- a/packages/backend/src/auth/guards/transaction-access.guard.ts +++ b/packages/backend/src/auth/guards/transaction-access.guard.ts @@ -1,8 +1,8 @@ import { - Injectable, CanActivate, ExecutionContext, ForbiddenException, + Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '@/database/prisma.service'; @@ -25,20 +25,24 @@ export class TransactionAccessGuard implements CanActivate { throw new ForbiddenException('Transaction ID not provided'); } - // Find transaction and its wallet const transaction = await this.prisma.transaction.findUnique({ where: { txId }, - include: { account: true }, + select: { accountAddress: true, chainId: true }, }); if (!transaction) { throw new NotFoundException('Transaction not found'); } - // Check if user is a member of the wallet + // Scope membership by both address AND chainId — the same address can + // live on multiple chains, and only the signer of the exact multisig + // (this transaction's chain) should be allowed access. const membership = await this.prisma.accountSigner.findFirst({ where: { - account: { address: transaction.account.address }, + account: { + address: transaction.accountAddress, + chainId: transaction.chainId, + }, user: { commitment: userCommitment }, }, }); diff --git a/packages/backend/src/common/utils/membership.ts b/packages/backend/src/common/utils/membership.ts index c132118a..9321a3cf 100644 --- a/packages/backend/src/common/utils/membership.ts +++ b/packages/backend/src/common/utils/membership.ts @@ -4,19 +4,26 @@ 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 + * + * Account lookup MUST be precise: either `accountId` (a CUID) or the composite + * `{ accountAddress, chainId }`. Using address alone is unsafe — the same + * address can exist on multiple chains, so an address-only check could match + * a signer of a different multisig that happens to share the address. */ export async function checkAccountMembership( prisma: PrismaService, - accountLookup: { accountId: string } | { accountAddress: string }, + accountLookup: + | { accountId: string } + | { accountAddress: string; chainId: number }, userCommitment: string, ): Promise { const accountWhere = 'accountId' in accountLookup ? { id: accountLookup.accountId } - : { address: accountLookup.accountAddress }; + : { + address: accountLookup.accountAddress, + chainId: accountLookup.chainId, + }; const membership = await prisma.accountSigner.findFirst({ where: { diff --git a/packages/backend/src/quest/quest.service.ts b/packages/backend/src/quest/quest.service.ts index 984a1605..0b897de9 100644 --- a/packages/backend/src/quest/quest.service.ts +++ b/packages/backend/src/quest/quest.service.ts @@ -80,8 +80,10 @@ export class QuestService { return 0; } - // Get account and creator - const account = await this.prisma.account.findUnique({ + // Get account and creator. Address is no longer globally unique + // (per-chain), so we use findFirst — quest module is currently disabled + // anyway and ambiguity is acceptable for points accounting. + const account = await this.prisma.account.findFirst({ where: { address: accountAddress }, include: { signers: { diff --git a/packages/backend/src/transaction/transaction.controller.ts b/packages/backend/src/transaction/transaction.controller.ts index 8d9c8b92..b5ff7e7d 100644 --- a/packages/backend/src/transaction/transaction.controller.ts +++ b/packages/backend/src/transaction/transaction.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -100,6 +101,13 @@ export class TransactionController { description: 'Account contract address', example: '0x1234567890abcdef1234567890abcdef12345678', }) + @ApiQuery({ + name: 'chainId', + required: true, + description: + 'Chain ID for the multisig (same address can exist on multiple chains)', + example: 8453, + }) @ApiQuery({ name: 'status', required: false, @@ -122,10 +130,12 @@ export class TransactionController { async getTransactions( @CurrentUser() user: User, @Query('accountAddress') accountAddress: string, + @Query('chainId') chainIdParam: string, @Query('status') status?: string, @Query('limit') limitParam?: string, @Query('cursor') cursor?: string, ) { + const chainId = parseRequiredChainId(chainIdParam); const limit = Math.min( parseInt(limitParam || String(DEFAULT_PAGE_SIZE), 10) || DEFAULT_PAGE_SIZE, @@ -134,6 +144,7 @@ export class TransactionController { return this.transactionService.getTransactions( accountAddress, + chainId, user.commitment, status, limit, @@ -311,12 +322,18 @@ export class TransactionController { @ApiBody({ schema: { type: 'object', + required: ['accountAddress', 'chainId'], properties: { accountAddress: { type: 'string', example: '0x1234567890abcdef1234567890abcdef12345678', description: 'Account contract address', }, + chainId: { + type: 'number', + example: 8453, + description: 'Chain ID of the multisig', + }, }, }, }) @@ -325,10 +342,27 @@ export class TransactionController { async reserveNonce( @CurrentUser() user: User, @Body('accountAddress') accountAddress: string, + @Body('chainId') chainId: number, ) { + if (typeof chainId !== 'number' || !Number.isFinite(chainId)) { + throw new BadRequestException('chainId is required in the request body'); + } return this.transactionService.reserveNonce( accountAddress, + chainId, user.commitment, ); } } + +// (address, chainId) is the real account key — refuse requests without chainId +// rather than silently picking the first matching account. +function parseRequiredChainId(value: string | undefined): number { + const chainId = Number.parseInt(value ?? '', 10); + if (!Number.isFinite(chainId) || chainId <= 0) { + throw new BadRequestException( + 'chainId query parameter is required (e.g. ?chainId=8453)', + ); + } + return chainId; +} diff --git a/packages/backend/src/transaction/transaction.service.ts b/packages/backend/src/transaction/transaction.service.ts index d3e4de84..bb090087 100644 --- a/packages/backend/src/transaction/transaction.service.ts +++ b/packages/backend/src/transaction/transaction.service.ts @@ -52,17 +52,19 @@ export class TransactionService { async createTransaction(dto: CreateTransactionDto, userCommitment: string) { await checkAccountMembership( this.prisma, - { accountAddress: dto.accountAddress }, + { accountAddress: dto.accountAddress, chainId: dto.chainId }, userCommitment, ); // 1. Validate based on type this.validateTransactionDto(dto); - // 2. Validate nonce is reserved + // 2. Validate nonce is reserved (scoped by chainId — same address on + // different chains has independent nonce reservations). const reserved = await this.prisma.reservedNonce.findFirst({ where: { accountAddress: dto.accountAddress, + chainId: dto.chainId, nonce: dto.nonce, expiresAt: { gt: new Date() }, }, @@ -74,9 +76,16 @@ export class TransactionService { ); } - // 3. Check account exists and get total signers count + // 3. Check account exists and get total signers count. (address, chainId) + // is the composite key — a wallet may produce identical addresses on + // different chains, so we must scope by chainId from the DTO. const account = await this.prisma.account.findUnique({ - where: { address: dto.accountAddress }, + where: { + address_chainId: { + address: dto.accountAddress, + chainId: dto.chainId, + }, + }, include: { signers: true }, }); @@ -140,6 +149,7 @@ export class TransactionService { nonce: dto.nonce, type: dto.type, accountAddress: dto.accountAddress, + chainId: dto.chainId, threshold: dto.threshold, to: dto.to, value: dto.value, @@ -274,6 +284,7 @@ export class TransactionService { const voterName = await this.getSignerDisplayName( transaction.accountAddress, + transaction.chainId, userCommitment, ); @@ -396,6 +407,7 @@ export class TransactionService { const voterName = await this.getSignerDisplayName( transaction.accountAddress, + transaction.chainId, userCommitment, ); @@ -451,6 +463,7 @@ export class TransactionService { */ async getTransactions( accountAddress: string, + chainId: number, userCommitment: string, status?: string, limit: number = DEFAULT_PAGE_SIZE, @@ -459,12 +472,12 @@ export class TransactionService { if (userCommitment) { await checkAccountMembership( this.prisma, - { accountAddress }, + { accountAddress, chainId }, userCommitment, ); } - const where: any = { accountAddress }; + const where: any = { accountAddress, chainId }; if (status) { where.status = status; } @@ -523,10 +536,14 @@ export class TransactionService { return this.transactionExecutor.executeOnChain(txId, userAddress); } - async reserveNonce(accountAddress: string, userCommitment: string) { + async reserveNonce( + accountAddress: string, + chainId: number, + userCommitment: string, + ) { await checkAccountMembership( this.prisma, - { accountAddress }, + { accountAddress, chainId }, userCommitment, ); @@ -536,15 +553,16 @@ export class TransactionService { where: { expiresAt: { lt: new Date() } }, }); - // 2. Find next available nonce + // 2. Find next available nonce (scoped to this chain — the same address + // on another chain has an independent on-chain nonce counter). const maxTxNonce = await tx.transaction.findFirst({ - where: { accountAddress }, + where: { accountAddress, chainId }, orderBy: { nonce: 'desc' }, select: { nonce: true }, }); const maxReservedNonce = await tx.reservedNonce.findFirst({ - where: { accountAddress }, + where: { accountAddress, chainId }, orderBy: { nonce: 'desc' }, select: { nonce: true }, }); @@ -558,7 +576,7 @@ export class TransactionService { const expiresAt = new Date(Date.now() + NONCE_RESERVATION_TTL); await tx.reservedNonce.create({ - data: { accountAddress, nonce: nextNonce, expiresAt }, + data: { accountAddress, chainId, nonce: nextNonce, expiresAt }, }); return { nonce: nextNonce, expiresAt }; @@ -751,11 +769,12 @@ export class TransactionService { private async getSignerDisplayName( accountAddress: string, + chainId: number, commitment: string, ): Promise { const signer = await this.prisma.accountSigner.findFirst({ where: { - account: { address: accountAddress }, + account: { address: accountAddress, chainId }, user: { commitment }, }, }); diff --git a/packages/backend/src/x402/x402.service.ts b/packages/backend/src/x402/x402.service.ts index 635e9de7..0e508013 100644 --- a/packages/backend/src/x402/x402.service.ts +++ b/packages/backend/src/x402/x402.service.ts @@ -151,7 +151,10 @@ export class X402Service { if (!/^0x[a-fA-F0-9]{40}$/.test(multisigAddress)) { throw new BadRequestException('Invalid address format'); } - const account = await this.prisma.account.findUnique({ + // x402 only supports Base chains, so we scope the lookup to those — this + // also disambiguates when the same address exists on multiple chains + // (Horizen + Base) due to wallet nonces colliding. + const account = await this.prisma.account.findFirst({ where: { address: multisigAddress.toLowerCase() }, }); if (!account) { diff --git a/packages/nextjs/components/Dashboard/DashboardContainer.tsx b/packages/nextjs/components/Dashboard/DashboardContainer.tsx index 46e270bc..995b8e7c 100644 --- a/packages/nextjs/components/Dashboard/DashboardContainer.tsx +++ b/packages/nextjs/components/Dashboard/DashboardContainer.tsx @@ -7,7 +7,7 @@ import InfoCardContainer from "./InfoCardContainer"; import { TransactionRow, convertToRowData } from "./TransactionRow"; import { useInfiniteScroll, useMetaMultiSigWallet } from "~~/hooks"; import { useTransactionRealtime, useTransactionsInfinite } from "~~/hooks/api/useTransaction"; -import { useIdentityStore } from "~~/services/store"; +import { useAccountStore, useIdentityStore } from "~~/services/store"; export interface WalletData { signers: string[]; @@ -28,13 +28,17 @@ function Header() { export default function DashboardContainer() { const { commitment } = useIdentityStore(); + const { currentAccount } = useAccountStore(); const metaMultiSigWallet = useMetaMultiSigWallet(); const accountAddress = metaMultiSigWallet?.address || ""; + const chainId = currentAccount?.chainId ?? 0; - const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage, refetch } = - useTransactionsInfinite(accountAddress); + const { data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage, refetch } = useTransactionsInfinite( + accountAddress, + chainId, + ); - useTransactionRealtime(accountAddress); + useTransactionRealtime(accountAddress, chainId); const { ref } = useInfiniteScroll({ hasNextPage, diff --git a/packages/nextjs/components/Dashboard/InfoCardContainer.tsx b/packages/nextjs/components/Dashboard/InfoCardContainer.tsx index af3ed133..01495d75 100644 --- a/packages/nextjs/components/Dashboard/InfoCardContainer.tsx +++ b/packages/nextjs/components/Dashboard/InfoCardContainer.tsx @@ -18,12 +18,12 @@ const InfoCardContainer: React.FC = () => { const accountAddress = metaMultiSigWallet?.address || ""; - const { data } = usePendingTransactions(accountAddress); - const { data: walletCommitments } = useWalletCommitments(); - const { currentAccount } = useAccountStore(); const { data: accounts = [] } = useMyAccounts(); const chainId = (currentAccount as any)?.chainId ?? getDefaultChainId(); + + const { data } = usePendingTransactions(accountAddress, chainId); + const { data: walletCommitments } = useWalletCommitments(); const avatarSrc = currentAccount ? getAccountAvatar(currentAccount, accounts) : "/sidebar/account-icon.svg"; // Memoize flattened transactions to avoid re-computing on every render diff --git a/packages/nextjs/components/Sidebar/AccountItem.tsx b/packages/nextjs/components/Sidebar/AccountItem.tsx index 19a4fb61..68d5175a 100644 --- a/packages/nextjs/components/Sidebar/AccountItem.tsx +++ b/packages/nextjs/components/Sidebar/AccountItem.tsx @@ -72,7 +72,7 @@ export default function AccountItem({ const trimmedName = editedName.trim(); if (trimmedName && trimmedName !== account.name) { updateAccount( - { address: account.address, dto: { name: trimmedName } }, + { address: account.address, chainId: account.chainId, dto: { name: trimmedName } }, { onError: () => { // Revert if error occurs diff --git a/packages/nextjs/components/modals/EditAccountModal/index.tsx b/packages/nextjs/components/modals/EditAccountModal/index.tsx index f5fe4c52..cc525a7d 100644 --- a/packages/nextjs/components/modals/EditAccountModal/index.tsx +++ b/packages/nextjs/components/modals/EditAccountModal/index.tsx @@ -8,7 +8,7 @@ import { ActionMode, ExistingSigner, ModalStep, PendingSigner } from "./types"; import ModalContainer from "~~/components/modals/ModalContainer"; import { WARNING_AUTO_HIDE } from "~~/constants/timing"; import { useAccount, useMetaMultiSigWallet, useSignerTransaction } from "~~/hooks"; -import { useIdentityStore, useSidebarStore } from "~~/services/store"; +import { useAccountStore, useIdentityStore, useSidebarStore } from "~~/services/store"; import { ModalProps } from "~~/types/modal"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -16,8 +16,9 @@ import { notification } from "~~/utils/scaffold-eth"; const EditAccountModal: React.FC = ({ isOpen, onClose }) => { const { commitment: myCommitment } = useIdentityStore(); const { closeManageAccounts } = useSidebarStore(); + const { currentAccount } = useAccountStore(); const metaMultiSigWallet = useMetaMultiSigWallet(); - const { data: account } = useAccount(metaMultiSigWallet?.address || ""); + const { data: account } = useAccount(metaMultiSigWallet?.address || "", currentAccount?.chainId ?? 0); const { addSigner, diff --git a/packages/nextjs/components/modals/SignerListModal.tsx b/packages/nextjs/components/modals/SignerListModal.tsx index f8a920cf..e9715781 100644 --- a/packages/nextjs/components/modals/SignerListModal.tsx +++ b/packages/nextjs/components/modals/SignerListModal.tsx @@ -4,13 +4,15 @@ import React from "react"; import Image from "next/image"; import ModalContainer from "./ModalContainer"; import { useAccount, useMetaMultiSigWallet } from "~~/hooks"; +import { useAccountStore } from "~~/services/store"; import { ModalProps } from "~~/types/modal"; import { copyToClipboard } from "~~/utils/copy"; import { formatAddress } from "~~/utils/format"; const SignerListModal: React.FC = ({ isOpen, onClose }) => { const metaMultiSigWallet = useMetaMultiSigWallet(); - const { data: account } = useAccount(metaMultiSigWallet?.address || ""); + const { currentAccount } = useAccountStore(); + const { data: account } = useAccount(metaMultiSigWallet?.address || "", currentAccount?.chainId ?? 0); return ( diff --git a/packages/nextjs/hooks/api/useAccount.ts b/packages/nextjs/hooks/api/useAccount.ts index 284166e1..82f45070 100644 --- a/packages/nextjs/hooks/api/useAccount.ts +++ b/packages/nextjs/hooks/api/useAccount.ts @@ -12,7 +12,7 @@ import { notification } from "~~/utils/scaffold-eth/notification"; export const accountKeys = { all: ["accounts"] as const, - byAddress: (address: string) => [...accountKeys.all, address] as const, + byAddress: (address: string, chainId: number) => [...accountKeys.all, address, chainId] as const, }; export const useCreateAccount = () => { @@ -22,7 +22,7 @@ export const useCreateAccount = () => { mutationFn: accountApi.create, onSuccess: data => { queryClient.invalidateQueries({ queryKey: accountKeys.all }); - queryClient.setQueryData(accountKeys.byAddress(data.address), data); + queryClient.setQueryData(accountKeys.byAddress(data.address, data.chainId), data); queryClient.invalidateQueries({ queryKey: userKeys.meAccounts }); queryClient.invalidateQueries({ queryKey: userKeys.me }); }, @@ -37,7 +37,7 @@ export const useCreateAccountBatch = () => { onSuccess: data => { queryClient.invalidateQueries({ queryKey: accountKeys.all }); data.forEach(account => { - queryClient.setQueryData(accountKeys.byAddress(account.address), account); + queryClient.setQueryData(accountKeys.byAddress(account.address, account.chainId), account); }); queryClient.invalidateQueries({ queryKey: userKeys.meAccounts }); queryClient.invalidateQueries({ queryKey: userKeys.me }); @@ -45,11 +45,11 @@ export const useCreateAccountBatch = () => { }); }; -export const useAccount = (address: string) => { +export const useAccount = (address: string, chainId: number) => { return useAuthenticatedQuery({ - queryKey: accountKeys.byAddress(address), - queryFn: () => accountApi.getByAddress(address), - enabled: !!address, + queryKey: accountKeys.byAddress(address, chainId), + queryFn: () => accountApi.getByAddress(address, chainId), + enabled: !!address && !!chainId, }); }; @@ -57,9 +57,10 @@ export const useUpdateAccount = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ address, dto }: { address: string; dto: UpdateAccountDto }) => accountApi.update(address, dto), + mutationFn: ({ address, chainId, dto }: { address: string; chainId: number; dto: UpdateAccountDto }) => + accountApi.update(address, chainId, dto), onSuccess: (data, variables) => { - queryClient.invalidateQueries({ queryKey: accountKeys.byAddress(variables.address) }); + queryClient.invalidateQueries({ queryKey: accountKeys.byAddress(variables.address, variables.chainId) }); queryClient.invalidateQueries({ queryKey: accountKeys.all }); queryClient.invalidateQueries({ queryKey: userKeys.meAccounts }); }, diff --git a/packages/nextjs/hooks/api/useTransaction.ts b/packages/nextjs/hooks/api/useTransaction.ts index 15a91cbe..bfb4bc4e 100644 --- a/packages/nextjs/hooks/api/useTransaction.ts +++ b/packages/nextjs/hooks/api/useTransaction.ts @@ -25,9 +25,9 @@ import { useIdentityStore } from "~~/services/store"; export const transactionKeys = { all: ["transactions"] as const, - byAccount: (accountAddress: string) => [...transactionKeys.all, accountAddress] as const, - byAccountAndStatus: (accountAddress: string, status: TxStatus) => - [...transactionKeys.all, accountAddress, status] as const, + byAccount: (accountAddress: string, chainId: number) => [...transactionKeys.all, accountAddress, chainId] as const, + byAccountAndStatus: (accountAddress: string, chainId: number, status: TxStatus) => + [...transactionKeys.all, accountAddress, chainId, status] as const, byTxId: (txId: number) => [...transactionKeys.all, "detail", txId] as const, }; @@ -43,7 +43,7 @@ export const useCreateTransaction = () => { mutationFn: transactionApi.create, onSuccess: (_, variables) => { queryClient.invalidateQueries({ - queryKey: transactionKeys.byAccount(variables.accountAddress), + queryKey: transactionKeys.byAccount(variables.accountAddress, variables.chainId), }); }, }); @@ -52,22 +52,22 @@ export const useCreateTransaction = () => { /** * Infinite scroll hook for transactions */ -export const useTransactionsInfinite = (accountAddress: string, status?: TxStatus) => { +export const useTransactionsInfinite = (accountAddress: string, chainId: number, status?: TxStatus) => { const { accessToken } = useIdentityStore(); return useInfiniteQuery({ queryKey: status - ? [...transactionKeys.byAccountAndStatus(accountAddress, status), "infinite"] - : [...transactionKeys.byAccount(accountAddress), "infinite"], + ? [...transactionKeys.byAccountAndStatus(accountAddress, chainId, status), "infinite"] + : [...transactionKeys.byAccount(accountAddress, chainId), "infinite"], queryFn: ({ pageParam }) => - transactionApi.getAll(accountAddress, status, { + transactionApi.getAll(accountAddress, chainId, status, { limit: DEFAULT_PAGE_SIZE, cursor: pageParam, }), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage: PaginatedResponse) => lastPage.hasMore ? lastPage.nextCursor : undefined, - enabled: !!accessToken && !!accountAddress, + enabled: !!accessToken && !!accountAddress && !!chainId, }); }; @@ -147,7 +147,8 @@ export const useExecuteTransaction = () => { */ export const useReserveNonce = () => { return useMutation({ - mutationFn: transactionApi.reserveNonce, + mutationFn: ({ accountAddress, chainId }: { accountAddress: string; chainId: number }) => + transactionApi.reserveNonce(accountAddress, chainId), }); }; @@ -156,35 +157,35 @@ export const useReserveNonce = () => { /** * Get pending transactions for an account */ -export const usePendingTransactions = (accountAddress: string) => { - return useTransactionsInfinite(accountAddress, TxStatus.PENDING); +export const usePendingTransactions = (accountAddress: string, chainId: number) => { + return useTransactionsInfinite(accountAddress, chainId, TxStatus.PENDING); }; /** * Listen for realtime transaction updates * Use this in components that display transaction list */ -export const useTransactionRealtime = (accountAddress: string | undefined) => { +export const useTransactionRealtime = (accountAddress: string | undefined, chainId: number | undefined) => { const queryClient = useQueryClient(); // Handle new transaction created const handleTxCreated = useCallback( (data: TxCreatedEventData) => { console.log("[Socket] TX created:", data); - if (accountAddress) { - queryClient.invalidateQueries({ queryKey: transactionKeys.byAccount(accountAddress) }); + if (accountAddress && chainId) { + queryClient.invalidateQueries({ queryKey: transactionKeys.byAccount(accountAddress, chainId) }); } }, - [queryClient, accountAddress], + [queryClient, accountAddress, chainId], ); // Handle transaction status change const handleTxStatus = useCallback( (data: TxStatusEventData) => { console.log("[Socket] TX status:", data); - if (accountAddress) { + if (accountAddress && chainId) { queryClient.invalidateQueries({ - queryKey: transactionKeys.byAccount(accountAddress), + queryKey: transactionKeys.byAccount(accountAddress, chainId), }); // Refetch contract data when tx executed @@ -198,20 +199,20 @@ export const useTransactionRealtime = (accountAddress: string | undefined) => { } } }, - [queryClient, accountAddress], + [queryClient, accountAddress, chainId], ); // Handle transaction voted const handleTxVoted = useCallback( (data: TxVotedEventData) => { console.log("[Socket] TX voted:", data); - if (accountAddress) { + if (accountAddress && chainId) { queryClient.invalidateQueries({ - queryKey: transactionKeys.byAccount(accountAddress), + queryKey: transactionKeys.byAccount(accountAddress, chainId), }); } }, - [queryClient, accountAddress], + [queryClient, accountAddress, chainId], ); // Subscribe to socket events diff --git a/packages/nextjs/hooks/api/useX402Deposit.ts b/packages/nextjs/hooks/api/useX402Deposit.ts index 29ef3ee6..7579d2fb 100644 --- a/packages/nextjs/hooks/api/useX402Deposit.ts +++ b/packages/nextjs/hooks/api/useX402Deposit.ts @@ -62,8 +62,10 @@ export function useX402Deposit() { }, onSuccess: (_data, params) => { notification.success("Deposit submitted"); + // x402 only operates on Base, so the chainId in the query key is the + // wallet's current connected chain. void queryClient.invalidateQueries({ - queryKey: accountKeys.byAddress(params.multisigAddress), + queryKey: accountKeys.byAddress(params.multisigAddress, chainId), }); }, onError: (err: Error) => { diff --git a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts index 82baa4aa..cee8b71d 100644 --- a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts @@ -6,7 +6,7 @@ import { useMetaMultiSigWallet } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; import { useStepLoading } from "~~/hooks/app/useStepLoading"; -import { useIdentityStore } from "~~/services/store"; +import { useAccountStore, useIdentityStore } from "~~/services/store"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -21,6 +21,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { const { data: walletClient } = useWalletClient(); const { secret, commitment: myCommitment } = useIdentityStore(); + const { currentAccount } = useAccountStore(); const metaMultiSigWallet = useMetaMultiSigWallet(); const { mutateAsync: createTransaction } = useCreateTransaction(); const { mutateAsync: reserveNonce } = useReserveNonce(); @@ -50,7 +51,10 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { // 1. Reserve nonce from backend startStep(1); - const { nonce } = await reserveNonce(metaMultiSigWallet.address); + const { nonce } = await reserveNonce({ + accountAddress: metaMultiSigWallet.address, + chainId: currentAccount!.chainId, + }); // 2. Get current threshold and commitments const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); @@ -85,6 +89,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { nonce, type: TxType.BATCH, accountAddress: metaMultiSigWallet.address, + chainId: currentAccount!.chainId, threshold: Number(currentThreshold), to: metaMultiSigWallet.address, value: "0", diff --git a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts index 30cfedb8..f64b3068 100644 --- a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts @@ -5,6 +5,7 @@ import { useWalletClient } from "wagmi"; import { useGenerateProof, useMetaMultiSigWallet, useWalletCommitments, useWalletThreshold } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api/useTransaction"; import { useStepLoading } from "~~/hooks/app/useStepLoading"; +import { useAccountStore } from "~~/services/store"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -19,6 +20,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); + const { currentAccount } = useAccountStore(); const { generateProof } = useGenerateProof({ onLoadingStateChange: setStepByLabel, }); @@ -49,7 +51,10 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { startStep(1); - const { nonce } = await reserveNonce(metaMultiSigWallet.address); + const { nonce } = await reserveNonce({ + accountAddress: metaMultiSigWallet.address, + chainId: currentAccount!.chainId, + }); const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); const txHash = (await metaMultiSigWallet.read.getTransactionHash([ @@ -67,6 +72,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { nonce, type, accountAddress: metaMultiSigWallet.address, + chainId: currentAccount!.chainId, threshold: Number(currentThreshold), proof, publicInputs, diff --git a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts index ccc4a1e4..1cbfebca 100644 --- a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts +++ b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts @@ -18,6 +18,7 @@ import { accountKeys, useMetaMultiSigWallet, userKeys } from "~~/hooks"; import { useApproveTransaction, useDenyTransaction, useExecuteTransaction } from "~~/hooks/api/useTransaction"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; import { useStepLoading } from "~~/hooks/app/useStepLoading"; +import { useAccountStore } from "~~/services/store"; import { useIdentityStore } from "~~/services/store/useIdentityStore"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -124,6 +125,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { useStepLoading(createTransactionSteps("approval")); const { commitment } = useIdentityStore(); + const { currentAccount } = useAccountStore(); const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); @@ -239,7 +241,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { queryClient.invalidateQueries({ queryKey: userKeys.all }); queryClient.invalidateQueries({ - queryKey: accountKeys.byAddress(metaMultiSigWallet?.address || ""), + queryKey: accountKeys.byAddress(metaMultiSigWallet?.address || "", currentAccount?.chainId ?? 0), }); options?.onSuccess?.(); } catch (error: any) { diff --git a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts index 261afb6a..d94700e0 100644 --- a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts @@ -7,6 +7,7 @@ import { useMetaMultiSigWallet } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api/useTransaction"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; import { useStepLoading } from "~~/hooks/app/useStepLoading"; +import { useAccountStore } from "~~/services/store"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -28,6 +29,7 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); + const { currentAccount } = useAccountStore(); const { mutateAsync: createTransaction } = useCreateTransaction(); const { mutateAsync: reserveNonce } = useReserveNonce(); const { generateProof } = useGenerateProof({ @@ -44,7 +46,10 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) try { // 1. Reserve nonce from backend startStep(1); - const { nonce } = await reserveNonce(metaMultiSigWallet.address); + const { nonce } = await reserveNonce({ + accountAddress: metaMultiSigWallet.address, + chainId: currentAccount!.chainId, + }); // 2. Get current threshold and commitments const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); @@ -85,6 +90,7 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) nonce, type: TxType.TRANSFER, accountAddress: metaMultiSigWallet.address, + chainId: currentAccount!.chainId, threshold: Number(currentThreshold), to: recipient, value: valueInSmallestUnit, diff --git a/packages/nextjs/services/api/accountApi.ts b/packages/nextjs/services/api/accountApi.ts index 9d08a56f..80c336e4 100644 --- a/packages/nextjs/services/api/accountApi.ts +++ b/packages/nextjs/services/api/accountApi.ts @@ -12,13 +12,19 @@ export const accountApi = { return data; }, - getByAddress: async (address: string): Promise => { - const { data } = await apiClient.get(API_ENDPOINTS.accounts.byAddress(address)); + // Address is no longer globally unique — `chainId` disambiguates which + // multisig (Horizen vs Base) when the same address exists on both chains. + getByAddress: async (address: string, chainId: number): Promise => { + const { data } = await apiClient.get(API_ENDPOINTS.accounts.byAddress(address), { + params: { chainId }, + }); return data; }, - update: async (address: string, dto: UpdateAccountDto): Promise => { - const { data } = await apiClient.patch(API_ENDPOINTS.accounts.byAddress(address), dto); + update: async (address: string, chainId: number, dto: UpdateAccountDto): Promise => { + const { data } = await apiClient.patch(API_ENDPOINTS.accounts.byAddress(address), dto, { + params: { chainId }, + }); return data; }, }; diff --git a/packages/nextjs/services/api/transactionApi.ts b/packages/nextjs/services/api/transactionApi.ts index 5bb1a3ed..f0ad06c1 100644 --- a/packages/nextjs/services/api/transactionApi.ts +++ b/packages/nextjs/services/api/transactionApi.ts @@ -26,11 +26,13 @@ export const transactionApi = { getAll: async ( accountAddress: string, + chainId: number, status?: TxStatus, pagination?: PaginationParams, ): Promise> => { const params = new URLSearchParams(); params.append("accountAddress", accountAddress); + params.append("chainId", String(chainId)); if (status) { params.append("status", status); @@ -100,8 +102,11 @@ export const transactionApi = { return data; }, - reserveNonce: async (accountAddress: string): Promise<{ nonce: number; expiresAt: string }> => { - const { data } = await apiClient.post(API_ENDPOINTS.transactions.reserveNonce, { accountAddress }); + reserveNonce: async (accountAddress: string, chainId: number): Promise<{ nonce: number; expiresAt: string }> => { + const { data } = await apiClient.post(API_ENDPOINTS.transactions.reserveNonce, { + accountAddress, + chainId, + }); return data; }, }; diff --git a/packages/shared/src/dto/transaction/create-transaction.dto.ts b/packages/shared/src/dto/transaction/create-transaction.dto.ts index ba6009c3..4c44a460 100644 --- a/packages/shared/src/dto/transaction/create-transaction.dto.ts +++ b/packages/shared/src/dto/transaction/create-transaction.dto.ts @@ -31,6 +31,13 @@ export class CreateTransactionDto { @Matches(ETH_ADDRESS_REGEX, { message: "Invalid account address" }) accountAddress: string; + // The chain the target multisig lives on. Required because the same + // (relayer, nonce) can produce identical addresses on different chains, + // so (accountAddress, chainId) is the real account key. + @IsNotEmpty() + @IsNumber() + chainId: number; + @IsNotEmpty() @IsNumber() @Min(1)