diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d0aa307b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,268 @@ +# CLAUDE.md - PolyPay App + +## Project Overview + +PolyPay is a privacy-preserving payroll platform built on Horizen blockchain. It enables organizations, DAOs, and global teams to run payroll privately using zero-knowledge proofs (Noir circuits). Key features: private payments, private multisig approvals, escrow/milestone-based transfers, real-time notifications via WebSocket, and JWT authentication. + +## Tech Stack + +**Monorepo** (Yarn Workspaces, Node.js >= 20.18.3) + +| Layer | Stack | +|-------|-------| +| Frontend | Next.js 15, React 19, TypeScript 5.8, Tailwind CSS 4 + DaisyUI 5 | +| State | Zustand 5 (persist middleware), TanStack React Query 5 | +| Forms | React Hook Form 7 + Zod 3 | +| Web3 | wagmi 2, viem 2, RainbowKit 2, Uniswap SDK | +| ZK | @noir-lang/noir_js, @aztec/bb.js | +| UI | Radix UI + shadcn/ui pattern, CVA for variants, Lucide React icons | +| Notifications | Sonner (primary), react-hot-toast (secondary) | +| Real-time | socket.io-client | +| Backend | NestJS 11, TypeScript 5.7, Prisma 7 (PostgreSQL), JWT auth | +| Smart Contracts | Hardhat 2, Solidity, OpenZeppelin 5, Poseidon circuits | +| Shared | @polypay/shared - DTOs with class-validator/class-transformer | + +## Folder Structure + +``` +/ +├── docker/ # Docker Compose, Dockerfiles, .env.example +├── docs/ # Gitbook documentation +├── packages/ +│ ├── nextjs/ # Frontend (Next.js App Router) +│ │ ├── app/ # Pages (dashboard, transfer, batch, contact-book, quest, leaderboard) +│ │ ├── components/ +│ │ │ ├── ui/ # Base UI components (shadcn pattern: button, input, dialog...) +│ │ │ ├── form/ # Form components (Form, FormField, FormInput, FormTextarea) +│ │ │ ├── Common/ # Shared components (Sidebar, DisclaimerChecker) +│ │ │ ├── Dashboard/ # Dashboard feature components +│ │ │ ├── Transfer/ # Transfer flow components +│ │ │ ├── Batch/ # Batch operation components +│ │ │ ├── NewAccount/ # Account creation components +│ │ │ ├── modals/ # Modal dialogs (~22 modals, lazy-loaded via ModalRegistry) +│ │ │ ├── skeletons/ # Loading skeleton components +│ │ │ └── icons/ # Custom icon components +│ │ ├── hooks/ +│ │ │ ├── api/ # React Query hooks (useTransaction, useAccount, useNotifications...) +│ │ │ ├── app/ # App logic hooks (useAuth, useGenerateProof, useSocketEvent...) +│ │ │ ├── form/ # Form hooks (useZodForm) +│ │ │ └── scaffold-eth/ # Scaffold-eth hooks +│ │ ├── services/ +│ │ │ ├── api/ # Axios API services (apiClient, authApi, transactionApi...) +│ │ │ ├── store/ # Zustand stores (useIdentityStore, useAccountStore...) +│ │ │ ├── web3/ # Web3 utilities +│ │ │ ├── socket/ # Socket.io client +│ │ │ └── queryClient.ts # React Query config +│ │ ├── utils/ # Utilities (formatError, errorHandler, signer, network...) +│ │ ├── lib/form/ # Form schemas (Zod) and validation helpers +│ │ ├── types/ # TypeScript types (modal, form, abitype) +│ │ ├── constants/ # Constants (API_BASE_URL, timing) +│ │ ├── configs/ # Route config, scaffold config +│ │ └── contracts/ # Contract ABIs +│ ├── backend/ # NestJS Backend +│ │ └── src/ +│ │ ├── auth/ # JWT authentication module +│ │ ├── account/ # Multi-sig account management +│ │ ├── transaction/ # Transaction CRUD & voting +│ │ ├── user/ # User management with ZK commitments +│ │ ├── notification/ # Real-time notifications (WebSocket) +│ │ ├── quest/ # Quest/gamification system +│ │ ├── admin/ # Admin analytics +│ │ ├── zkverify/ # ZK proof verification (Horizen) +│ │ ├── common/ # Shared utilities & middleware +│ │ ├── database/ # Prisma module +│ │ └── ... # ~24 feature modules total +│ ├── shared/ # Shared DTOs and types (@polypay/shared) +│ └── hardhat/ # Smart contracts & deployment +``` + +## Validation Commands + +```bash +# Root level +yarn lint # ESLint (frontend + hardhat) +yarn next:check-types # TypeScript type checking (frontend) +yarn next:build # Production build (frontend) +yarn build # Build all packages (shared → backend → frontend) +yarn test # Run hardhat tests +yarn format # Prettier format all packages + +# Frontend (packages/nextjs) +yarn dev # Dev server (port 3000) +yarn build # Next.js production build +yarn lint # ESLint +yarn check-types # TypeScript check +yarn format # Prettier + +# Backend (packages/backend) +yarn start:dev # Dev server with watch (port 4000) +yarn lint # ESLint +yarn format # Prettier +yarn test # Jest unit tests +yarn test:cov # Jest with coverage +yarn test:e2e # End-to-end tests +yarn test:e2e:staging # Staging E2E tests + +# Smart Contracts (packages/hardhat) +yarn chain # Local Hardhat node +yarn compile # Compile contracts +yarn deploy # Deploy contracts +yarn test # Hardhat tests +``` + +## Code Style & Conventions + +### Naming +- **Components**: PascalCase files and exports (`FormField.tsx`, `NotificationPanel.tsx`) +- **Hooks**: camelCase with `use` prefix (`useZodForm.ts`, `useTransaction.ts`) +- **Utils/Services**: camelCase (`apiClient.ts`, `formatError.ts`) +- **Types/Interfaces**: PascalCase (`ModalProps`, `IdentityState`) +- **Stores**: camelCase with `use` prefix (`useIdentityStore.ts`, `useAccountStore.ts`) +- **API services**: camelCase with `Api` suffix (`transactionApi`, `authApi`) +- **Constants**: UPPER_SNAKE_CASE (`API_BASE_URL`, `DEFAULT_PAGE_SIZE`) + +### Import Order +1. React / Next.js +2. Third-party libraries +3. @heroicons +4. @polypay/shared +5. Local imports (`~~/...`) + +### Formatting (Prettier) +- Print width: 120 +- Tab width: 2 spaces +- Arrow parens: avoid +- Trailing comma: all +- Path alias: `~~/*` → project root, `@polypay/shared` → shared package + +### Component Pattern +- "use client" directive for client-side components +- Functional components with hooks +- Named exports for UI components, default exports for pages +- Props interfaces defined inline in component files + +## Common Patterns + +### Design Tokens & Colors +- **Source of truth**: `packages/nextjs/styles/tokens.ts` — all color definitions live here +- **Sync script**: `packages/nextjs/styles/sync-colors.ts` — generates CSS variables into `globals.css` and Tailwind color config into `tailwind.config.ts` +- **Command**: `yarn sync-colors` (from `packages/nextjs`) +- **Flow**: Edit `tokens.ts` → run `yarn sync-colors` → CSS vars + Tailwind config auto-updated +- **Never edit colors directly** in `globals.css` or `tailwind.config.ts` — they will be overwritten by the sync script + +### State Management (Zustand) +```typescript +// services/store/useIdentityStore.ts +// All stores use create() with persist middleware for localStorage +const useIdentityStore = create()( + persist( + (set) => ({ + accessToken: null, + // ...state and actions + setTokens: (access, refresh) => set({ accessToken: access, refreshToken: refresh }), + logout: () => set({ accessToken: null, isAuthenticated: false }), + }), + { name: "identity-storage" } + ) +); +// Use getState() for sync access outside React (e.g., in Axios interceptors) +``` + +### API Services +```typescript +// services/api/transactionApi.ts +// Object export pattern with typed async methods +export const transactionApi = { + getAll: async (params) => { const { data } = await apiClient.get(...); return data; }, + create: async (dto) => { const { data } = await apiClient.post(...); return data; }, +}; +``` + +### React Query Hooks +```typescript +// hooks/api/useTransaction.ts +// Query key factory pattern for cache invalidation +const transactionKeys = { all: ["transactions"], list: (filters) => [...] }; +// useInfiniteQuery for pagination, useMutation with onSuccess invalidation +``` + +### Forms (React Hook Form + Zod) +```typescript +// useZodForm hook → Form component → FormField with Controller +const form = useZodForm({ schema: contactSchema }); +// Zod schemas in lib/form/schemas.ts, reusable validators in lib/form/validation.ts +``` + +### Error Handling +```typescript +// utils/formatError.ts - pattern matching for friendly error messages +// utils/errorHandler.ts - ErrorCode enum, parseError(), handleError() +// Axios interceptor handles 401 auto-refresh with token rotation +``` + +### Notifications +```typescript +import { notification } from "~~/utils/scaffold-eth"; +notification.success("Transaction submitted!"); +notification.error(formatErrorMessage(error)); +``` + +### Real-time (Socket.io) +```typescript +// hooks/app/useSocketEvent.ts - wrapper with auto-cleanup on unmount +useSocketEvent("transaction:updated", (data) => { /* handle */ }); +``` + +### Modals +```typescript +// Lazy-loaded via ModalRegistry with dynamic imports to prevent SSR issues +// components/modals/ - ~22 modal components +``` + +### Authenticated Queries +```typescript +// hooks/api/useAuthenticatedQuery.ts +// Wrapper that auto-disables queries when user is not authenticated +``` + +### Routes +```typescript +// configs/routes.config.ts - centralized route definitions +// hooks/app/useAppRouter.ts - type-safe navigation (goToDashboard, goToTransfer, etc.) +``` + +## Environment Setup + +### Prerequisites +- Node.js >= 20.18.3 +- Yarn (workspaces) +- Docker & Docker Compose (for PostgreSQL) + +## Do's and Don'ts + +### Do's +- Use `@polypay/shared` DTOs for API contracts between frontend and backend +- Use `useZodForm` hook for all forms with Zod schemas +- Use `notification.success/error/info` for user feedback +- Use `formatErrorMessage()` for user-friendly error messages +- Use `useAuthenticatedQuery` wrapper for queries requiring auth +- Use query key factory pattern for React Query cache management +- Use `useSocketEvent` hook for real-time subscriptions (auto-cleanup) +- Use `~~/*` path alias for local imports +- Use Zustand `persist` middleware for state that needs to survive refresh +- Use `apiClient` (configured Axios instance) for all API calls +- Follow feature-based component organization +- Use Radix UI + CVA for new UI components (shadcn pattern) + +### Don'ts +- Don't import from `@polypay/shared` directly in smart contracts +- Don't make API calls without going through `apiClient` (it handles auth tokens) +- Don't create new notification systems - use existing `notification` utility or Sonner +- Don't hardcode API URLs - use `API_BASE_URL` from constants +- Don't skip Zod validation for forms - always define schemas +- Don't store auth tokens manually - use `useIdentityStore` +- Don't create new Zustand stores without `persist` middleware unless state is truly ephemeral +- Don't use `useQuery` directly for authenticated endpoints - use `useAuthenticatedQuery` +- Don't put business logic in components - extract to custom hooks in `hooks/app/` +- Don't use inline styles - use Tailwind CSS classes +- Don't commit `.env` files or expose secrets diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 0b766a90..930dcd62 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -1,3 +1,8 @@ +# E2E / Testing +# -------------- +# Base URL for staging backend used by transaction.staging.e2e-spec.ts +STAGING_API_BASE_URL=https://api.testnet.polypay.pro + # Database DATABASE_URL="postgresql://polypay_user:polypay_password@localhost:5433/polypay_multisig_db" diff --git a/packages/backend/package.json b/packages/backend/package.json index f9d5e92d..e00b7e2b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,6 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e:staging": "jest --config ./test/jest-staging-e2e.json", "report": "ts-node scripts/generate-analytics-report.ts" }, "dependencies": { diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index f2dbb9c8..c5b6b2e1 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -1,45 +1,45 @@ -import * as request from 'supertest'; -import { type Hex, formatEther } from 'viem'; import { + resetDatabase, setupTestApp, teardownTestApp, - resetDatabase, - getHttpServer, - getTestApp, } from '../setup'; import { getSignerA, getSignerB } from '../fixtures/test-users'; -import { createTestSigner, TestSigner } from '../utils/signer.util'; -import { - generateSecret, - generateCommitment, - generateTestProof, -} from '../utils/proof.util'; +import { loginUser, AuthTokens } from '../utils/auth.util'; +import { TestIdentity, createTestIdentity } from '../utils/identity.util'; import { - depositToAccount, - getTransactionHash, - getAccountBalance, -} from '../utils/contract.util'; -import { getPrismaService } from '../utils/cleanup.util'; -import { loginUser, getAuthHeader, AuthTokens } from '../utils/auth.util'; + apiCreateAccount, + apiCreateBatchItem, + apiReserveNonce, + apiCreateTransaction, + apiApproveTransaction, + apiExecuteTransaction, + apiGetTransaction, + generateVotePayload, +} from '../utils/transaction.util'; +import { CreateAccountDto, TxStatus, TxType } from '@polypay/shared'; import { - API_ENDPOINTS, - CreateAccountDto, - TxStatus, - TxType, -} from '@polypay/shared'; - -// Timeout 5 minutes for blockchain calls -jest.setTimeout(600000); + TEST_CHAIN_ID, + TEST_RECIPIENT, + TEST_THRESHOLD, + TEST_TRANSFER_AMOUNT, + SCENARIOS, + type CreatedTx, + type ScenarioAmount, + type ParsedBatchItem, + getCreatedTxLabel, + buildSingleTransferParams, + buildSingleApproveParams, + buildBatchCallData, + buildBatchCallDataFromParsed, + fundAccountForScenario, +} from '../utils/multi-asset-flow.shared'; + +// Timeout 20 minutes for blockchain calls +jest.setTimeout(1200000); describe('Transaction E2E', () => { - let signerA: TestSigner; - let signerB: TestSigner; - let secretA: bigint; - let secretB: bigint; - let commitmentA: string; - let commitmentB: string; - let signerDtoA: any; - let signerDtoB: any; + let identityA: TestIdentity; + let identityB: TestIdentity; let tokensA: AuthTokens; let tokensB: AuthTokens; @@ -47,43 +47,15 @@ describe('Transaction E2E', () => { // Setup NestJS app await setupTestApp(); - // Setup test signers - signerA = createTestSigner(getSignerA()); - signerB = createTestSigner(getSignerB()); - - // Generate secrets and commitments - secretA = await generateSecret(signerA); - secretB = await generateSecret(signerB); - - const commitmentABigInt = await generateCommitment(secretA); - const commitmentBBigInt = await generateCommitment(secretB); - - commitmentA = commitmentABigInt.toString(); - commitmentB = commitmentBBigInt.toString(); - signerDtoA = { - commitment: commitmentA, - name: 'Signer A', - }; - signerDtoB = { - commitment: commitmentB, - name: 'Signer B', - }; - - console.log('Test setup complete:'); - console.log(' Signer A address:', signerA.address); - console.log(' Signer B address:', signerB.address); - console.log(' Commitment A:', commitmentA); - console.log(' Commitment B:', commitmentB); + identityA = await createTestIdentity(getSignerA, 'Signer A'); + identityB = await createTestIdentity(getSignerB, 'Signer B'); }); beforeEach(async () => { await resetDatabase(); // Login both users - console.log('\n--- Login Users ---'); - tokensA = await loginUser(secretA, commitmentA); - tokensB = await loginUser(secretB, commitmentB); - console.log(' User A logged in'); - console.log(' User B logged in'); + tokensA = await loginUser(identityA.secret, identityA.commitment); + tokensB = await loginUser(identityB.secret, identityB.commitment); }); afterAll(async () => { @@ -91,171 +63,262 @@ describe('Transaction E2E', () => { }); describe('Full transaction flow', () => { - it('should complete full flow: create account → create tx → approve → execute', async () => { - const server = getHttpServer(); + it('should complete full flow for ETH, ZEN and USDC transfers', async () => { + console.log('\n=== Multi-asset Transaction E2E: Start ==='); // ============ STEP 1: Create Account ============ - console.log('\n--- Step 1: Create Account ---'); - + console.log('Phase 1: Create Account - start'); + const createdAt = new Date().toISOString().replace('T', ' ').slice(0, 19); const dataCreateAccount: CreateAccountDto = { - name: 'Test Multi-Sig Account', - signers: [signerDtoA, signerDtoB], - threshold: 2, - chainId: 2651420, + name: `Multi-Sig Account ${createdAt}`, + signers: [identityA.signerDto, identityB.signerDto], + threshold: TEST_THRESHOLD, + chainId: TEST_CHAIN_ID, }; - const accountResponse = await request(server) - .post(API_ENDPOINTS.accounts.base) - .set(getAuthHeader(tokensA.accessToken)) - .send(dataCreateAccount) - .expect(201); - - expect(accountResponse.body).toHaveProperty('address'); - - const accountAddress = accountResponse.body.address as `0x${string}`; - console.log('Account created:'); - console.log(' Address:', accountAddress); - - // ============ STEP 2: Deposit ETH to Account ============ - console.log('\n--- Step 2: Deposit ETH to Account ---'); - - const balanceBefore = await getAccountBalance(accountAddress); - console.log(' Balance before:', formatEther(balanceBefore), 'ETH'); - - await depositToAccount(signerA, accountAddress, '0.001'); - - const balanceAfter = await getAccountBalance(accountAddress); - console.log(' Balance after:', formatEther(balanceAfter), 'ETH'); - - expect(balanceAfter).toBeGreaterThan(balanceBefore); - - // ============ STEP 3: Create Transaction ============ - console.log('\n--- Step 3: Create Transaction ---'); - - // 3.1 Reserve nonce - const reserveNonceResponse = await request(server) - .post(API_ENDPOINTS.transactions.reserveNonce) - .set(getAuthHeader(tokensA.accessToken)) - .send({ accountAddress }) - .expect(201); - - const nonce = reserveNonceResponse.body.nonce; - console.log(' Reserved nonce:', nonce); - - // 3.2 Prepare transfer params - const recipient = - '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; - const value = BigInt('1000000000000000'); // 0.001 ETH - const callData = '0x' as Hex; - - // 3.3 Get txHash from contract - const txHash = await getTransactionHash( - accountAddress, - BigInt(nonce), - recipient, - value, - callData, + const { address: accountAddress } = await apiCreateAccount( + tokensA.accessToken, + dataCreateAccount, ); - console.log(' TxHash from contract:', txHash); - - // 3.4 Generate proof for signer A - console.log(' Generating proof for Signer A...'); - const proofA = await generateTestProof(signerA, secretA, txHash); - console.log(' Proof A generated'); - - // 3.5 Create transaction - const createTxResponse = await request(server) - .post(API_ENDPOINTS.transactions.base) - .set(getAuthHeader(tokensA.accessToken)) - .send({ - nonce: nonce, + console.log('Phase 1: Create Account - done', { + accountAddress, + }); + + // ============ STEP 2: Fund account for all scenarios ============ + console.log('Phase 2: Fund account for all scenarios - start'); + const scenarioAmounts: ScenarioAmount[] = []; + + for (const scenario of SCENARIOS) { + console.log(`[${scenario.name}] Funding start`); + const funded = await fundAccountForScenario( + scenario, + accountAddress, + identityA, + ); + scenarioAmounts.push(funded); + console.log(`[${scenario.name}] Funding recorded`, { + amount: TEST_TRANSFER_AMOUNT, + }); + } + console.log('Phase 2: Fund account for all scenarios - done'); + + // ============ STEP 3: Create Transactions (3 single + 1 batch) ============ + console.log('Phase 3: Create transactions - start'); + const createdTxs: CreatedTx[] = []; + + // 3 single transfers (ETH, ZEN, USDC) + for (const amount of scenarioAmounts) { + console.log(`[${amount.scenario.name}] Create single transaction - start`); + + const { nonce } = await apiReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const { to, value, callData } = buildSingleTransferParams( + amount, + TEST_RECIPIENT, + ); + + const votePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(nonce), + to, + value, + callData, + ); + + const { txId } = await apiCreateTransaction(tokensA.accessToken, { + nonce, type: TxType.TRANSFER, - accountAddress: accountAddress, - to: recipient, - value: value.toString(), - threshold: 2, - creatorCommitment: commitmentA, - proof: proofA.proof, - publicInputs: proofA.publicInputs, - nullifier: proofA.nullifier, - }) - .expect(201); - - const txId = createTxResponse.body.txId; - console.log(' Transaction created, txId:', txId); - - // ============ STEP 4: Approve Transaction (Signer B) ============ - console.log('\n--- Step 4: Approve Transaction (Signer B) ---'); - - // 4.1 Get transaction details - const getTxResponse = await request(server) - .get(API_ENDPOINTS.transactions.byTxId(txId)) - .set(getAuthHeader(tokensA.accessToken)) - .expect(200); - - console.log(' Transaction status:', getTxResponse.body.status); - - // 4.2 Get txHash for approve - const txHashForApprove = await getTransactionHash( + accountAddress, + to: TEST_RECIPIENT, + value: amount.amountString, + threshold: TEST_THRESHOLD, + proof: votePayloadA.proof, + publicInputs: votePayloadA.publicInputs, + nullifier: votePayloadA.nullifier, + ...(amount.scenario.isNative + ? {} + : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + }); + + 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) + console.log('Batch: Create batch items - start'); + const batchItemIds: string[] = []; + for (const amount of scenarioAmounts) { + const item = await apiCreateBatchItem(tokensA.accessToken, { + recipient: TEST_RECIPIENT, + amount: amount.amountString, + tokenAddress: amount.scenario.isNative + ? undefined + : (amount.scenario.tokenAddress as string), + }); + batchItemIds.push(item.id); + } + console.log('Batch: Create batch items - done', { batchItemIds }); + + const batchCallData = buildBatchCallData(scenarioAmounts, TEST_RECIPIENT); + + const { nonce: batchNonce } = await apiReserveNonce( + tokensA.accessToken, accountAddress, - BigInt(getTxResponse.body.nonce), - getTxResponse.body.to as `0x${string}`, - BigInt(getTxResponse.body.value), - callData, ); - // 4.3 Generate proof for signer B - console.log(' Generating proof for Signer B...'); - const proofB = await generateTestProof( - signerB, - secretB, - txHashForApprove, + const batchVotePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(batchNonce), + accountAddress as `0x${string}`, + 0n, + batchCallData, ); - console.log(' Proof B generated'); - - // 4.4 Approve transaction - await request(server) - .post(API_ENDPOINTS.transactions.approve(txId)) - .set(getAuthHeader(tokensB.accessToken)) - .send({ - voterCommitment: commitmentB, - proof: proofB.proof, - publicInputs: proofB.publicInputs, - nullifier: proofB.nullifier, - }) - .expect(201); - - console.log(' Transaction approved by Signer B'); - - // ============ STEP 5: Execute Transaction ============ - console.log('\n--- Step 5: Execute Transaction ---'); - const executeResponse = await request(server) - .post(API_ENDPOINTS.transactions.execute(txId)) - .set(getAuthHeader(tokensA.accessToken)) - .expect(201); - - expect(executeResponse.body).toHaveProperty('txHash'); - console.log(' Execute TxHash:', executeResponse.body.txHash); - - // ============ STEP 6: Verify Final State ============ - console.log('\n--- Step 6: Verify Final State ---'); - - const prisma = getPrismaService(getTestApp()); - - const finalTx = await prisma.transaction.findUnique({ - where: { txId: txId }, - include: { votes: true }, - }); - - expect(finalTx).not.toBeNull(); - expect(finalTx!.status).toBe(TxStatus.EXECUTED); - expect(finalTx!.votes.length).toBe(2); - - console.log('Final verification:'); - console.log(' Status:', finalTx!.status); - console.log(' Vote count:', finalTx!.votes.length); - console.log('\n✅ Full transaction flow completed successfully!'); + const { txId: batchTxId } = await apiCreateTransaction( + tokensA.accessToken, + { + nonce: batchNonce, + type: TxType.BATCH, + accountAddress, + to: accountAddress, + value: '0', + threshold: TEST_THRESHOLD, + proof: batchVotePayloadA.proof, + publicInputs: batchVotePayloadA.publicInputs, + nullifier: batchVotePayloadA.nullifier, + batchItemIds, + }, + ); + createdTxs.push({ kind: 'batch', txId: batchTxId }); + console.log('Batch: Create batch transaction - done', { batchTxId }); + + console.log('Phase 3: Create transactions - done'); + + // ============ STEP 4: Approve all 4 Transactions (Signer B) ============ + console.log('Phase 4: Approve transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Approve transaction - start`, { txId }); + + const txDetails = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { + nonce: number; + to?: string; + value?: string; + tokenAddress?: string | null; + batchData?: string; + }; + + if (entry.kind === 'batch') { + if (txDetails.batchData == null) { + throw new Error(`Batch tx ${txId} missing batchData`); + } + 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}`, + 0n, + callDataApprove, + ); + await apiApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } else { + const { to: toApprove, value: valueApprove, callData: callDataApprove } = + buildSingleApproveParams(txDetails); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + toApprove, + valueApprove, + callDataApprove, + ); + await apiApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } + + console.log(`[${label}] Approve transaction - done`, { txId }); + } + console.log('Phase 4: Approve transactions - done'); + + // ============ STEP 5: Execute all 4 Transactions sequentially ============ + console.log('Phase 5: Execute transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Execute transaction - start`, { txId }); + + const { txHash } = await apiExecuteTransaction( + tokensA.accessToken, + txId, + ); + expect(txHash).toBeDefined(); + + console.log(`[${label}] Execute transaction - done`, { txId, txHash }); + } + console.log('Phase 5: Execute transactions - done'); + + // ============ STEP 6: Verify Final State for all 4 transactions ============ + console.log('Phase 6: Verify final state - start'); + + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + + const finalTx = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { + status: TxStatus; + votes: unknown[]; + tokenAddress?: string | null; + value?: string; + } | null; + + expect(finalTx).not.toBeNull(); + expect(finalTx!.status).toBe(TxStatus.EXECUTED); + expect(finalTx!.votes.length).toBe(2); + + if (entry.kind === 'single') { + if (entry.scenario.isNative) { + expect(finalTx!.tokenAddress).toBeNull(); + } else { + expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + (entry.scenario.tokenAddress as string).toLowerCase(), + ); + } + expect(finalTx!.value).toBe(entry.amount.amountString); + } + + console.log(`[${label}] Final verification - done`, { + status: finalTx?.status, + votes: finalTx?.votes.length, + }); + } + + console.log('Phase 6: Verify final state - done'); + console.log('=== Multi-asset Transaction E2E: Done ===\n'); }); }); }); diff --git a/packages/backend/test/e2e/transaction.staging.e2e-spec.ts b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts new file mode 100644 index 00000000..472f4e3c --- /dev/null +++ b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts @@ -0,0 +1,345 @@ +import { getSignerA, getSignerB } from '../fixtures/test-users'; +import { TestIdentity, createTestIdentity } from '../utils/identity.util'; +import { + stagingLogin, + stagingCreateAccount, + stagingReserveNonce, + stagingCreateTransaction, + stagingApproveTransaction, + stagingExecuteTransaction, + stagingGetTransaction, + stagingCreateBatchItem, +} from '../utils/staging-api.util'; +import { CreateAccountDto, TxStatus, TxType } from '@polypay/shared'; +import { generateVotePayload } from '../utils/transaction.util'; +import { + TEST_CHAIN_ID, + TEST_RECIPIENT, + TEST_THRESHOLD, + TEST_TRANSFER_AMOUNT, + SCENARIOS, + type CreatedTx, + type ScenarioAmount, + type ParsedBatchItem, + getCreatedTxLabel, + buildSingleTransferParams, + buildSingleApproveParams, + buildBatchCallData, + buildBatchCallDataFromParsed, + fundAccountForScenario, +} from '../utils/multi-asset-flow.shared'; + +async function waitForExecuted( + accessToken: string, + txId: string, + label: string, +): Promise { + const maxAttempts = 20; + const intervalMs = 15000; + + let lastStatus: string | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const tx = await stagingGetTransaction(accessToken, txId); + lastStatus = tx.status; + + if (tx.status === TxStatus.EXECUTED) { + return tx; + } + + console.log( + `[${label}] Waiting for EXECUTED - attempt ${attempt}, status=${tx.status}`, + ); + + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + + throw new Error( + `[${label}] Transaction ${txId} did not reach EXECUTED within timeout. Last status=${lastStatus}`, + ); +} + +// Timeout 20 minutes for blockchain calls +jest.setTimeout(1200000); + +describe('Transaction Staging E2E', () => { + let identityA: TestIdentity; + let identityB: TestIdentity; + let tokensA: { accessToken: string; refreshToken: string }; + let tokensB: { accessToken: string; refreshToken: string }; + + beforeAll(async () => { + identityA = await createTestIdentity(getSignerA, 'Signer A'); + identityB = await createTestIdentity(getSignerB, 'Signer B'); + }); + + beforeEach(async () => { + tokensA = await stagingLogin(identityA.secret, identityA.commitment); + tokensB = await stagingLogin(identityB.secret, identityB.commitment); + }); + + describe('Full transaction flow (staging)', () => { + it('should complete full flow for ETH, ZEN and USDC transfers + batch on staging', async () => { + console.log('\n=== Multi-asset Transaction E2E (Staging): Start ==='); + + // ============ STEP 1: Create Account ============ + console.log('Phase 1: Create Account - start'); + const createdAt = new Date().toISOString().replace('T', ' ').slice(0, 19); + const dataCreateAccount: CreateAccountDto = { + name: `Multi-Sig Account ${createdAt}`, + signers: [identityA.signerDto, identityB.signerDto], + threshold: TEST_THRESHOLD, + chainId: TEST_CHAIN_ID, + }; + + const { address: accountAddress } = await stagingCreateAccount( + tokensA.accessToken, + dataCreateAccount, + ); + console.log('Phase 1: Create Account - done', { + accountAddress, + }); + + // ============ STEP 2: Fund account for all scenarios ============ + console.log('Phase 2: Fund account for all scenarios - start'); + const scenarioAmounts: ScenarioAmount[] = []; + + for (const scenario of SCENARIOS) { + console.log(`[${scenario.name}] Funding start`); + const funded = await fundAccountForScenario( + scenario, + accountAddress, + identityA, + ); + scenarioAmounts.push(funded); + console.log(`[${scenario.name}] Funding recorded`, { + amount: TEST_TRANSFER_AMOUNT, + }); + } + console.log('Phase 2: Fund account for all scenarios - done'); + + // ============ STEP 3: Create Transactions (3 single + 1 batch) ============ + console.log('Phase 3: Create transactions - start'); + const createdTxs: CreatedTx[] = []; + + // 3 single transfers (ETH, ZEN, USDC) + for (const amount of scenarioAmounts) { + console.log(`[${amount.scenario.name}] Create single transaction - start`); + + const { nonce } = await stagingReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const { to, value, callData } = buildSingleTransferParams( + amount, + TEST_RECIPIENT, + ); + + const votePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(nonce), + to, + value, + callData, + ); + + const { txId } = await stagingCreateTransaction(tokensA.accessToken, { + nonce, + type: TxType.TRANSFER, + accountAddress, + to: TEST_RECIPIENT, + value: amount.amountString, + threshold: TEST_THRESHOLD, + proof: votePayloadA.proof, + publicInputs: votePayloadA.publicInputs, + nullifier: votePayloadA.nullifier, + ...(amount.scenario.isNative + ? {} + : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + }); + + 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) + console.log('Batch: Create batch items - start'); + const batchItemIds: string[] = []; + for (const amount of scenarioAmounts) { + const item = await stagingCreateBatchItem(tokensA.accessToken, { + recipient: TEST_RECIPIENT, + amount: amount.amountString, + tokenAddress: amount.scenario.isNative + ? undefined + : (amount.scenario.tokenAddress as string), + }); + batchItemIds.push(item.id); + } + console.log('Batch: Create batch items - done', { batchItemIds }); + + const batchCallData = buildBatchCallData( + scenarioAmounts, + TEST_RECIPIENT, + ); + + const { nonce: batchNonce } = await stagingReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const batchVotePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(batchNonce), + accountAddress as `0x${string}`, + 0n, + batchCallData, + ); + + const { txId: batchTxId } = await stagingCreateTransaction( + tokensA.accessToken, + { + nonce: batchNonce, + type: TxType.BATCH, + accountAddress, + to: accountAddress, + value: '0', + threshold: TEST_THRESHOLD, + proof: batchVotePayloadA.proof, + publicInputs: batchVotePayloadA.publicInputs, + nullifier: batchVotePayloadA.nullifier, + batchItemIds, + }, + ); + createdTxs.push({ kind: 'batch', txId: batchTxId }); + console.log('Batch: Create batch transaction - done', { batchTxId }); + + console.log('Phase 3: Create transactions - done'); + + // ============ STEP 4: Approve all 4 Transactions (Signer B) ============ + console.log('Phase 4: Approve transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Approve transaction - start`, { txId }); + + const txDetails = (await stagingGetTransaction( + tokensA.accessToken, + txId, + )) as { + nonce: number; + to?: string; + value?: string; + tokenAddress?: string | null; + batchData?: string; + }; + + if (entry.kind === 'batch') { + if (txDetails.batchData == null) { + throw new Error(`Batch tx ${txId} missing batchData`); + } + 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}`, + 0n, + callDataApprove, + ); + await stagingApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } else { + const { to: toApprove, value: valueApprove, callData: callDataApprove } = + buildSingleApproveParams(txDetails); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + toApprove, + valueApprove, + callDataApprove, + ); + await stagingApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } + + console.log(`[${label}] Approve transaction - done`, { txId }); + } + console.log('Phase 4: Approve transactions - done'); + + // ============ STEP 5: Execute all 4 Transactions sequentially ============ + console.log('Phase 5: Execute transactions - start'); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + console.log(`[${label}] Execute transaction - start`, { txId }); + + const { txHash } = await stagingExecuteTransaction( + tokensA.accessToken, + txId, + ); + expect(txHash).toBeDefined(); + + console.log(`[${label}] Execute transaction - done`, { txId, txHash }); + } + console.log('Phase 5: Execute transactions - done'); + + // ============ STEP 6: Verify Final State for all 4 transactions ============ + console.log('Phase 6: Verify final state - start'); + + for (const entry of createdTxs) { + const txId = entry.txId; + const label = getCreatedTxLabel(entry); + + const finalTx = (await waitForExecuted( + tokensA.accessToken, + txId, + label, + )) as { + status: TxStatus; + votes: unknown[]; + tokenAddress?: string | null; + value?: string; + } | null; + + expect(finalTx).not.toBeNull(); + expect(finalTx!.status).toBe(TxStatus.EXECUTED); + + if (entry.kind === 'single') { + if (entry.scenario.isNative) { + expect(finalTx!.tokenAddress).toBeNull(); + } else { + expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + (entry.scenario.tokenAddress as string).toLowerCase(), + ); + } + expect(finalTx!.value).toBe(entry.amount.amountString); + } + + console.log(`[${label}] Final verification - done`, { + status: finalTx?.status, + }); + } + + console.log('Phase 6: Verify final state - done'); + console.log('=== Multi-asset Transaction E2E (Staging): Done ===\n'); + }); + }); +}); + diff --git a/packages/backend/test/jest-e2e.json b/packages/backend/test/jest-e2e.json index 85eb9f6f..0fbc892b 100644 --- a/packages/backend/test/jest-e2e.json +++ b/packages/backend/test/jest-e2e.json @@ -2,7 +2,7 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", + "testRegex": "transaction.e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/packages/backend/test/jest-staging-e2e.json b/packages/backend/test/jest-staging-e2e.json new file mode 100644 index 00000000..8165d845 --- /dev/null +++ b/packages/backend/test/jest-staging-e2e.json @@ -0,0 +1,19 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": "transaction.staging.e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@polypay/shared$": "/../../shared/src", + "^@polypay/shared/(.*)$": "/../../shared/src/$1", + "^@/(.*)$": "/../src/$1" + }, + "testTimeout": 1200000, + "verbose": true, + "forceExit": true, + "detectOpenHandles": true +} + diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts index ee6c6a52..c82a2c4d 100644 --- a/packages/backend/test/jest.setup.ts +++ b/packages/backend/test/jest.setup.ts @@ -6,11 +6,17 @@ // Increase timeout for blockchain calls jest.setTimeout(300000); -// Load environment variables from .env.test if exists +// Load environment variables from .env.test if exists, otherwise fallback to .env import * as dotenv from 'dotenv'; import * as path from 'path'; +import * as fs from 'fs'; -dotenv.config({ path: path.resolve(__dirname, '../.env.test') }); +const envTestPath = path.resolve(__dirname, '../.env.test'); +const envPath = fs.existsSync(envTestPath) + ? envTestPath + : path.resolve(__dirname, '../.env'); + +dotenv.config({ path: envPath }); // Validate required environment variables const requiredEnvVars = [ diff --git a/packages/backend/test/utils/identity.util.ts b/packages/backend/test/utils/identity.util.ts new file mode 100644 index 00000000..0454fe02 --- /dev/null +++ b/packages/backend/test/utils/identity.util.ts @@ -0,0 +1,34 @@ +import { createTestSigner, TestSigner } from './signer.util'; +import { generateCommitment, generateSecret } from './proof.util'; +import { type TestUser } from '../fixtures/test-users'; + +export interface TestIdentity { + signer: TestSigner; + secret: bigint; + commitment: string; + signerDto: { + commitment: string; + name: string; + }; +} + +export async function createTestIdentity( + getUser: () => TestUser, + name: string, +): Promise { + const signer = createTestSigner(getUser()); + const secret = await generateSecret(signer); + const commitmentBigInt = await generateCommitment(secret); + const commitment = commitmentBigInt.toString(); + + return { + signer, + secret, + commitment, + signerDto: { + commitment, + name, + }, + }; +} + diff --git a/packages/backend/test/utils/multi-asset-flow.shared.ts b/packages/backend/test/utils/multi-asset-flow.shared.ts new file mode 100644 index 00000000..de7a16b4 --- /dev/null +++ b/packages/backend/test/utils/multi-asset-flow.shared.ts @@ -0,0 +1,231 @@ +import { type Hex, formatEther, parseEther } from 'viem'; +import { + encodeERC20Transfer, + encodeBatchTransferMulti, + formatTokenAmount, + ZEN_TOKEN, + USDC_TOKEN, + ZERO_ADDRESS, +} from '@polypay/shared'; +import { depositToAccount, getAccountBalance } from './contract.util'; +import { + toTokenAmount, + transferErc20FromSigner, + getErc20Balance, +} from './transaction.util'; +import type { TestIdentity } from './identity.util'; + +export const TEST_CHAIN_ID = 2651420; +export const TEST_TRANSFER_AMOUNT = '0.0001'; +export const TEST_FUND_ETH_AMOUNT = '0.0003'; +export const TEST_FUND_ERC20_MULTIPLIER = 2; +export const TEST_RECIPIENT = + '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; +export const TEST_THRESHOLD = 2; + +export type AssetName = 'ETH' | 'ZEN' | 'USDC'; + +export interface AssetScenario { + name: AssetName; + isNative: boolean; + tokenAddress: `0x${string}` | null; + decimals: number; +} + +export const SCENARIOS: AssetScenario[] = [ + { + name: 'ETH', + isNative: true, + tokenAddress: null, + decimals: 18, + }, + { + name: 'ZEN', + isNative: false, + tokenAddress: ZEN_TOKEN.addresses[TEST_CHAIN_ID] as `0x${string}`, + decimals: ZEN_TOKEN.decimals, + }, + { + name: 'USDC', + isNative: false, + tokenAddress: USDC_TOKEN.addresses[TEST_CHAIN_ID] as `0x${string}`, + decimals: USDC_TOKEN.decimals, + }, +]; + +export interface ScenarioAmount { + scenario: AssetScenario; + amountBigInt: bigint; + amountString: string; +} + +export type CreatedTx = + | { + kind: 'single'; + scenario: AssetScenario; + amount: ScenarioAmount; + txId: string; + } + | { kind: 'batch'; txId: string }; + +export interface ParsedBatchItem { + recipient: string; + amount: string; + tokenAddress?: string | null; +} + +export function getCreatedTxLabel(entry: CreatedTx): string { + return entry.kind === 'single' ? entry.scenario.name : 'batch'; +} + +/** Match frontend: ETH = (recipient, value, 0x); ERC20 = (tokenAddress, 0, encodeERC20Transfer) */ +export function buildSingleTransferParams( + amount: ScenarioAmount, + recipient: `0x${string}`, +): { to: `0x${string}`; value: bigint; callData: Hex } { + const to = amount.scenario.isNative + ? recipient + : (amount.scenario.tokenAddress as `0x${string}`); + const value = amount.scenario.isNative ? amount.amountBigInt : 0n; + const callData = amount.scenario.isNative + ? ('0x' as Hex) + : (encodeERC20Transfer(recipient, amount.amountBigInt) as Hex); + return { to, value, callData }; +} + +/** Build approve params from API tx details (single transfer). */ +export function buildSingleApproveParams(txDetails: { + to?: string; + value?: string; + tokenAddress?: string | null; +}): { to: `0x${string}`; value: bigint; callData: Hex } { + if (txDetails.to === undefined || txDetails.value === undefined) { + throw new Error('Single transfer txDetails must have to and value'); + } + const to = (txDetails.tokenAddress ?? txDetails.to) as `0x${string}`; + const value = txDetails.tokenAddress ? 0n : BigInt(txDetails.value); + const callData = txDetails.tokenAddress + ? (encodeERC20Transfer(txDetails.to, BigInt(txDetails.value)) as Hex) + : ('0x' as Hex); + return { to, value, callData }; +} + +export function buildBatchCallData( + scenarioAmounts: ScenarioAmount[], + recipient: `0x${string}`, +): Hex { + 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}`), + ); + return encodeBatchTransferMulti( + recipients, + amounts, + tokenAddresses as string[], + ) as Hex; +} + +export function buildBatchCallDataFromParsed( + parsedBatch: ParsedBatchItem[], +): 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; +} + +/** + * Fund account: 2x per asset (single tx 0.0001 + batch item 0.0001). + * Returns ScenarioAmount for one transfer (0.0001) used by both single tx and batch item. + */ +export async function fundAccountForScenario( + scenario: AssetScenario, + accountAddress: `0x${string}`, + identityA: TestIdentity, +): Promise { + if (scenario.isNative) { + const balanceBefore = await getAccountBalance(accountAddress); + await depositToAccount( + identityA.signer, + accountAddress, + TEST_FUND_ETH_AMOUNT, + ); + const balanceAfter = await getAccountBalance(accountAddress); + + console.log(`[${scenario.name}] Fund native ETH - done`, { + balanceBefore: formatEther(balanceBefore), + balanceAfter: formatEther(balanceAfter), + }); + + const value = parseEther(TEST_TRANSFER_AMOUNT); + return { + scenario, + amountBigInt: value, + amountString: value.toString(), + }; + } + + if (!scenario.tokenAddress) { + throw new Error( + `Token address is required for ERC20 scenario: ${scenario.name}`, + ); + } + + const fundHuman = ( + parseFloat(TEST_TRANSFER_AMOUNT) * TEST_FUND_ERC20_MULTIPLIER + ).toFixed(scenario.decimals); + const { amountBigInt: fundAmount } = toTokenAmount( + fundHuman, + scenario.decimals, + ); + const { amountBigInt, amountString } = toTokenAmount( + TEST_TRANSFER_AMOUNT, + scenario.decimals, + ); + + const balanceBefore = await getErc20Balance( + accountAddress, + scenario.tokenAddress, + ); + + await transferErc20FromSigner( + identityA.signer, + scenario.tokenAddress, + accountAddress, + fundAmount, + ); + + const balanceAfter = await getErc20Balance( + accountAddress, + scenario.tokenAddress, + ); + + console.log(`[${scenario.name}] Fund ERC20 (2x) - done`, { + tokenAddress: scenario.tokenAddress, + balanceBefore: formatTokenAmount( + balanceBefore.toString(), + scenario.decimals, + ), + balanceAfter: formatTokenAmount( + balanceAfter.toString(), + scenario.decimals, + ), + }); + + return { + scenario, + amountBigInt, + amountString, + }; +} diff --git a/packages/backend/test/utils/staging-api.util.ts b/packages/backend/test/utils/staging-api.util.ts new file mode 100644 index 00000000..ad7699d3 --- /dev/null +++ b/packages/backend/test/utils/staging-api.util.ts @@ -0,0 +1,197 @@ +import axios from 'axios'; +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'; + +const BASE_URL = + process.env.STAGING_API_BASE_URL || 'https://api.testnet.polypay.pro'; + +function authHeaders(accessToken: string) { + return { Authorization: `Bearer ${accessToken}` }; +} + +export async function stagingLogin( + secret: bigint, + commitment: string, +): Promise { + const authProof = await generateTestAuthProof(secret); + + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.auth.login}`, + { + commitment, + proof: authProof.proof, + publicInputs: authProof.publicInputs, + }, + ); + + return { + accessToken: response.data.accessToken, + refreshToken: response.data.refreshToken, + }; +} + +export async function stagingCreateAccount( + accessToken: string, + dto: CreateAccountDto, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.accounts.base}`, + dto, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { address: `0x${string}` }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingCreateAccount error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingCreateBatchItem( + accessToken: string, + payload: CreateBatchItemPayload, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.batchItems.base}`, + payload, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { id: string }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingCreateBatchItem error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingReserveNonce( + accessToken: string, + accountAddress: `0x${string}`, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.reserveNonce}`, + { accountAddress }, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { nonce: number }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingReserveNonce error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingCreateTransaction( + accessToken: string, + payload: CreateTransactionPayload, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.base}`, + payload, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { txId: string }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingCreateTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingApproveTransaction( + accessToken: string, + txId: string, + payload: ApproveTransactionPayload, +) { + try { + await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.approve(Number(txId))}`, + payload, + { headers: authHeaders(accessToken) }, + ); + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingApproveTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingExecuteTransaction( + accessToken: string, + txId: string, +) { + try { + const response = await axios.post( + `${BASE_URL}${API_ENDPOINTS.transactions.execute(Number(txId))}`, + undefined, + { headers: authHeaders(accessToken) }, + ); + + return response.data as { txHash: string }; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingExecuteTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + +export async function stagingGetTransaction( + accessToken: string, + txId: string, +) { + try { + const response = await axios.get( + `${BASE_URL}${API_ENDPOINTS.transactions.byTxId(Number(txId))}`, + { headers: authHeaders(accessToken) }, + ); + + return response.data; + } catch (error: any) { + if (axios.isAxiosError(error)) { + console.error('stagingGetTransaction error', { + status: error.response?.status, + data: error.response?.data, + }); + } + throw error; + } +} + diff --git a/packages/backend/test/utils/transaction.util.ts b/packages/backend/test/utils/transaction.util.ts new file mode 100644 index 00000000..695846aa --- /dev/null +++ b/packages/backend/test/utils/transaction.util.ts @@ -0,0 +1,250 @@ +import * as request from 'supertest'; +import { type Hex } from 'viem'; +import { + API_ENDPOINTS, + CreateAccountDto, + TxType, +} from '@polypay/shared'; +import { parseTokenAmount } from '@polypay/shared'; +import { getHttpServer } from '../setup'; +import { getAuthHeader } from './auth.util'; +import { createTestPublicClient, getTransactionHash } from './contract.util'; +import { generateTestProof } from './proof.util'; +import { type TestIdentity } from './identity.util'; +import { type TestSigner } from './signer.util'; +import { waitForReceiptWithRetry } from '@/common/utils/retry'; + +export interface CreateTransactionPayload { + nonce: number; + type: TxType; + accountAddress: `0x${string}`; + to: `0x${string}`; + value: string; + threshold: number; + tokenAddress?: string | null; + proof: number[]; + publicInputs: string[]; + nullifier: string; + /** For TxType.BATCH: IDs from apiCreateBatchItem (order = execution order) */ + batchItemIds?: string[]; +} + +export interface ApproveTransactionPayload { + proof: number[]; + publicInputs: string[]; + nullifier: string; +} + +export interface ParsedTokenAmount { + amountString: string; + amountBigInt: bigint; +} + +export async function apiCreateAccount( + accessToken: string, + dto: CreateAccountDto, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.accounts.base) + .set(getAuthHeader(accessToken)) + .send(dto) + .expect(201); + + return response.body as { address: `0x${string}` }; +} + +export async function apiReserveNonce( + accessToken: string, + accountAddress: `0x${string}`, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.transactions.reserveNonce) + .set(getAuthHeader(accessToken)) + .send({ accountAddress }) + .expect(201); + + return response.body as { nonce: number }; +} + +export async function apiCreateTransaction( + accessToken: string, + payload: CreateTransactionPayload, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.transactions.base) + .set(getAuthHeader(accessToken)) + .send(payload) + .expect(201); + + return response.body as { txId: string }; +} + +export async function apiApproveTransaction( + accessToken: string, + txId: string, + payload: ApproveTransactionPayload, +) { + const server = getHttpServer(); + + await request(server) + .post(API_ENDPOINTS.transactions.approve(Number(txId))) + .set(getAuthHeader(accessToken)) + .send(payload) + .expect(201); +} + +export async function apiExecuteTransaction( + accessToken: string, + txId: string, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.transactions.execute(Number(txId))) + .set(getAuthHeader(accessToken)) + .expect(201); + + return response.body as { txHash: string }; +} + +export async function apiGetTransaction(accessToken: string, txId: string) { + const server = getHttpServer(); + + const response = await request(server) + .get(API_ENDPOINTS.transactions.byTxId(Number(txId))) + .set(getAuthHeader(accessToken)) + .expect(200); + + return response.body; +} + +export interface CreateBatchItemPayload { + recipient: string; + amount: string; + tokenAddress?: string | null; +} + +export async function apiCreateBatchItem( + accessToken: string, + payload: CreateBatchItemPayload, +) { + const server = getHttpServer(); + + const response = await request(server) + .post(API_ENDPOINTS.batchItems.base) + .set(getAuthHeader(accessToken)) + .send(payload) + .expect(201); + + return response.body as { id: string }; +} + +export async function generateVotePayload( + identity: TestIdentity, + accountAddress: `0x${string}`, + nonce: bigint, + to: `0x${string}`, + value: bigint, + callData: Hex = '0x', +): Promise { + const txHash = await getTransactionHash( + accountAddress, + nonce, + to, + value, + callData, + ); + + const proof = await generateTestProof(identity.signer, identity.secret, txHash); + + return { + proof: proof.proof, + publicInputs: proof.publicInputs, + nullifier: proof.nullifier, + }; +} + +/** + * Convert human-readable token amount to smallest unit (string + bigint) + * using shared parseTokenAmount helper. + */ +export function toTokenAmount( + humanAmount: string, + decimals: number, +): ParsedTokenAmount { + const amountString = parseTokenAmount(humanAmount, decimals); + return { + amountString, + amountBigInt: BigInt(amountString), + }; +} + +/** + * Transfer ERC20 tokens from a test signer to a recipient address. + * Uses standard ERC20 transfer(address,uint256) and waits for confirmation. + */ +export async function transferErc20FromSigner( + signer: TestSigner, + tokenAddress: `0x${string}`, + to: `0x${string}`, + amount: bigint, +): Promise { + const hash = await signer.walletClient.writeContract({ + account: signer.account, + address: tokenAddress, + abi: [ + { + name: 'transfer', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'success', internalType: 'bool' as const }], + }, + ], + functionName: 'transfer', + args: [to, amount], + } as any); + + const publicClient = createTestPublicClient(); + await waitForReceiptWithRetry(publicClient as any, hash); + + return hash as Hex; +} + +/** + * Read ERC20 token balance for an address. + */ +export async function getErc20Balance( + accountAddress: `0x${string}`, + tokenAddress: `0x${string}`, +): Promise { + const publicClient = createTestPublicClient(); + + const balance = await publicClient.readContract({ + address: tokenAddress, + abi: [ + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: 'balance', type: 'uint256' }], + }, + ], + functionName: 'balanceOf', + args: [accountAddress], + }); + + return balance as bigint; +} + + diff --git a/packages/nextjs/app/contact-book/page.tsx b/packages/nextjs/app/contact-book/page.tsx index 20509837..6c293322 100644 --- a/packages/nextjs/app/contact-book/page.tsx +++ b/packages/nextjs/app/contact-book/page.tsx @@ -119,7 +119,7 @@ export default function AddressBookPage() {
+
diff --git a/packages/nextjs/components/Batch/BatchContainer.tsx b/packages/nextjs/components/Batch/BatchContainer.tsx index e8ccad3b..febcefca 100644 --- a/packages/nextjs/components/Batch/BatchContainer.tsx +++ b/packages/nextjs/components/Batch/BatchContainer.tsx @@ -292,6 +292,8 @@ export default function BatchContainer() { proposeBatch, isLoading: isProposing, loadingState, + loadingStep, + totalSteps, } = useBatchTransaction({ onSuccess: async () => { setSelectedItems(new Set()); @@ -439,6 +441,8 @@ export default function BatchContainer() { onConfirm={handleProposeBatch} isLoading={isProposing} loadingState={loadingState} + loadingStep={loadingStep} + totalSteps={totalSteps} accountId={accountId} /> @@ -452,6 +456,8 @@ export default function BatchContainer() { onConfirm={handleProposeBatch} isLoading={isProposing} loadingState={loadingState} + loadingStep={loadingStep} + totalSteps={totalSteps} accountId={accountId} /> diff --git a/packages/nextjs/components/Batch/TransactionSummary.tsx b/packages/nextjs/components/Batch/TransactionSummary.tsx index 97121066..c3f44842 100644 --- a/packages/nextjs/components/Batch/TransactionSummary.tsx +++ b/packages/nextjs/components/Batch/TransactionSummary.tsx @@ -19,6 +19,8 @@ interface TransactionSummaryProps { isLoading?: boolean; loadingState?: string; accountId: string | null; + loadingStep?: number; + totalSteps?: number; } const TransactionSummary: React.FC = ({ @@ -28,6 +30,8 @@ const TransactionSummary: React.FC = ({ isLoading = false, loadingState = "", accountId, + loadingStep = 0, + totalSteps = 4, }) => { const { data: contacts = [] } = useContacts(accountId); return ( @@ -116,13 +120,26 @@ const TransactionSummary: React.FC = ({ {/* Confirm Button Section */}
+ {isLoading && loadingState && loadingStep > 0 && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )}
diff --git a/packages/nextjs/components/Batch/TransactionSummaryDrawer.tsx b/packages/nextjs/components/Batch/TransactionSummaryDrawer.tsx index 7b17e2f1..8f24dbfe 100644 --- a/packages/nextjs/components/Batch/TransactionSummaryDrawer.tsx +++ b/packages/nextjs/components/Batch/TransactionSummaryDrawer.tsx @@ -19,6 +19,8 @@ interface TransactionSummaryDrawerProps { onConfirm?: () => void; isLoading?: boolean; loadingState?: string; + loadingStep?: number; + totalSteps?: number; } export const TransactionSummaryDrawer = memo(function TransactionSummaryDrawer({ @@ -29,6 +31,8 @@ export const TransactionSummaryDrawer = memo(function TransactionSummaryDrawer({ onConfirm, isLoading = false, loadingState = "", + loadingStep = 0, + totalSteps = 4, }: TransactionSummaryDrawerProps) { const [isAnimating, setIsAnimating] = useState(false); @@ -68,6 +72,8 @@ export const TransactionSummaryDrawer = memo(function TransactionSummaryDrawer({ accountId={accountId} isLoading={isLoading} loadingState={loadingState} + loadingStep={loadingStep} + totalSteps={totalSteps} className="h-full" />
diff --git a/packages/nextjs/components/Dashboard/TransactionRow.tsx b/packages/nextjs/components/Dashboard/TransactionRow.tsx index ba7bfef3..1b19adf0 100644 --- a/packages/nextjs/components/Dashboard/TransactionRow.tsx +++ b/packages/nextjs/components/Dashboard/TransactionRow.tsx @@ -632,7 +632,17 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) { // Get totalSigners realtime from wallet commitments const totalSigners = commitmentsData?.length || 0; - const { approve, deny, execute, isLoading: loading, loadingState } = useTransactionVote({ onSuccess }); + const { + approve, + deny, + execute, + isLoading: loading, + loadingState, + loadingStep, + totalSteps, + } = useTransactionVote({ + onSuccess, + }); const handleApprove = async () => { await approve(tx); @@ -718,7 +728,16 @@ export function TransactionRow({ tx, onSuccess }: TransactionRowProps) {
{/* Loading State */} {loading && loadingState && ( -
{loadingState}
+
+
+ {loadingStep > 0 && totalSteps > 1 && ( + + Step {loadingStep}/{totalSteps} + + )} + {loadingState} +
+
)} {/* Main Container */} diff --git a/packages/nextjs/components/Transfer/TransferContainer.tsx b/packages/nextjs/components/Transfer/TransferContainer.tsx index be46f515..e8e603b9 100644 --- a/packages/nextjs/components/Transfer/TransferContainer.tsx +++ b/packages/nextjs/components/Transfer/TransferContainer.tsx @@ -6,6 +6,7 @@ import { ResolvedToken, parseTokenAmount } from "@polypay/shared"; import { parseEther } from "viem"; import { ContactPicker } from "~~/components/contact-book/ContactPicker"; import { TokenPillPopover } from "~~/components/popovers/TokenPillPopover"; +import { Spinner } from "~~/components/ui/Spinner"; import { useMetaMultiSigWallet, useTransferTransaction } from "~~/hooks"; import { useCreateBatchItem } from "~~/hooks/api"; import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; @@ -69,7 +70,7 @@ export default function TransferContainer() { } }, [form]); - const { transfer, isLoading, loadingState } = useTransferTransaction({ + const { transfer, isLoading, loadingState, loadingStep, totalSteps } = useTransferTransaction({ onSuccess: () => { form.reset(); setSelectedContactId(null); @@ -145,15 +146,15 @@ export default function TransferContainer() { return (
{/* Background images */} -
+
Top globe
-
+
Bottom globe
{/* Main content */} -
+
{/* Title section */}
transfering
@@ -164,11 +165,6 @@ export default function TransferContainer() {
- {/* Loading state */} - {isLoading && loadingState && ( -
{loadingState}
- )} -
{/* Action buttons */} + {isLoading && loadingState && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )}
diff --git a/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx new file mode 100644 index 00000000..001effc1 --- /dev/null +++ b/packages/nextjs/components/modals/CreateBatchFromContactsModal.tsx @@ -0,0 +1,482 @@ +"use client"; + +import { useCallback, 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 { parseUnits } from "viem"; +import { Checkbox } from "~~/components/Common"; +import ModalContainer from "~~/components/modals/ModalContainer"; +import { TokenPillPopover } from "~~/components/popovers/TokenPillPopover"; +import { useContacts, useCreateBatchItem, useGroups } from "~~/hooks"; +import { useBatchTransaction } from "~~/hooks"; +import { useNetworkTokens } from "~~/hooks/app/useNetworkTokens"; +import { formatAddress } from "~~/utils/format"; +import { notification } from "~~/utils/scaffold-eth"; + +interface BatchContactEntry { + contact: Contact; + amount: string; + tokenAddress: string; +} + +interface CreateBatchFromContactsModalProps { + isOpen: boolean; + onClose: () => void; + accountId?: string; + [key: string]: any; +} + +type Step = 1 | 2 | 3; + +export default function CreateBatchFromContactsModal({ + isOpen, + onClose, + accountId, +}: CreateBatchFromContactsModalProps) { + const [step, setStep] = useState(1); + const [selectedContactIds, setSelectedContactIds] = useState>(new Set()); + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [batchEntries, setBatchEntries] = useState([]); + + 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 { mutateAsync: createBatchItem } = useCreateBatchItem(); + const { + proposeBatch, + isLoading: isProposing, + loadingState, + loadingStep, + totalSteps, + } = useBatchTransaction({ + onSuccess: () => { + notification.success("Batch transaction created!"); + handleReset(); + onClose(); + }, + }); + + const defaultToken = nativeEth || tokens[0]; + + const handleReset = () => { + setStep(1); + setSelectedContactIds(new Set()); + setSelectedGroupId(null); + setBatchEntries([]); + }; + + const handleClose = () => { + if (isProposing) return; + handleReset(); + onClose(); + }; + + // Step 1: Contact Selection + const toggleContact = useCallback((contactId: string) => { + setSelectedContactIds(prev => { + const next = new Set(prev); + if (next.has(contactId)) { + next.delete(contactId); + } else { + next.add(contactId); + } + return next; + }); + }, []); + + const handleSelectAll = useCallback(() => { + if (selectedContactIds.size === contacts.length && contacts.length > 0) { + setSelectedContactIds(new Set()); + } else { + setSelectedContactIds(new Set(contacts.map(c => c.id))); + } + }, [selectedContactIds.size, contacts]); + + const allSelected = contacts.length > 0 && selectedContactIds.size === contacts.length; + + const goToStep2 = () => { + const selectedContacts = allContacts.filter(c => selectedContactIds.has(c.id)); + setBatchEntries( + selectedContacts.map(contact => ({ + contact, + amount: "", + tokenAddress: defaultToken?.address || ZERO_ADDRESS, + })), + ); + setStep(2); + }; + + // Step 2: Amount & Token + const updateEntryAmount = (index: number, amount: string) => { + setBatchEntries(prev => prev.map((entry, i) => (i === index ? { ...entry, amount } : entry))); + }; + + const updateEntryToken = (index: number, tokenAddress: string) => { + setBatchEntries(prev => prev.map((entry, i) => (i === index ? { ...entry, tokenAddress } : entry))); + }; + + const removeEntry = (index: number) => { + setBatchEntries(prev => prev.filter((_, i) => i !== index)); + }; + + const allEntriesFilled = batchEntries.length > 0 && batchEntries.every(e => e.amount && parseFloat(e.amount) > 0); + + const goToStep3 = () => { + setStep(3); + }; + + // Step 3: Execute + const resolveToken = (tokenAddress: string) => { + return tokens.find(t => t.address === tokenAddress) || defaultToken; + }; + + const handleProposeBatch = async () => { + try { + const createdItems = await Promise.all( + batchEntries.map(entry => { + const token = resolveToken(entry.tokenAddress); + const amountInSmallestUnit = parseUnits(entry.amount, token.decimals).toString(); + + const dto: CreateBatchItemDto = { + recipient: entry.contact.address, + amount: amountInSmallestUnit, + tokenAddress: entry.tokenAddress === ZERO_ADDRESS ? undefined : entry.tokenAddress, + contactId: entry.contact.id, + }; + return createBatchItem(dto); + }), + ); + await proposeBatch(createdItems); + } catch { + // Error is handled inside proposeBatch / createBatchItem + } + }; + + const title = step === 1 ? "Choose contact" : step === 2 ? "Add to batch" : "Transactions summary"; + + return ( + + {/* Header */} +
+ {step > 1 ? ( + + ) : ( +
+ )} +

{title}

+ +
+ + {/* Content */} +
+ {step === 1 && ( + + )} + + {step === 2 && ( + + )} + + {step === 3 && } +
+ + {/* Footer */} +
+ +
+ {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( +
+ {isProposing && loadingState && loadingStep > 0 && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )} + +
+ )} +
+
+ + ); +} + +// --- Step 1: Choose Contact --- +function StepChooseContact({ + contacts, + groups, + selectedGroupId, + selectedContactIds, + onSelectGroup, + onToggleContact, + onSelectAll, + allSelected, +}: { + contacts: Contact[]; + groups: ContactGroup[]; + selectedGroupId: string | null; + selectedContactIds: Set; + onSelectGroup: (id: string | null) => void; + onToggleContact: (id: string) => void; + onSelectAll: () => void; + allSelected: boolean; +}) { + return ( +
+ {/* Group filter tabs */} +
+ + {groups.map((group: ContactGroup) => ( + + ))} +
+ + {/* Select all / count */} +
+ + {selectedContactIds.size} selected +
+ + {/* Contact list */} +
+ {contacts.map((contact: Contact) => { + const isSelected = selectedContactIds.has(contact.id); + return ( +
+
onToggleContact(contact.id)}> + + avatar + {contact.name} +
+
+ + {formatAddress(contact.address, { start: 4, end: 4 })} + + +
+
+ ); + })} +
+
+ ); +} + +// --- Step 2: Add to Batch --- +function StepAddToBatch({ + entries, + resolveToken, + onUpdateAmount, + onUpdateToken, + onRemove, +}: { + entries: BatchContactEntry[]; + resolveToken: (address: string) => any; + onUpdateAmount: (index: number, amount: string) => void; + onUpdateToken: (index: number, tokenAddress: string) => void; + onRemove: (index: number) => void; +}) { + return ( +
+ {entries.map((entry, index) => { + const selectedToken = resolveToken(entry.tokenAddress); + return ( +
+
+ + Transfer + avatar +
+ {entry.contact.name} + + {formatAddress(entry.contact.address, { start: 4, end: 4 })} + +
+
+ +
+
+ { + const val = e.target.value; + if (val === "" || /^\d*\.?\d*$/.test(val)) { + onUpdateAmount(index, val); + } + }} + className="flex-1 text-base font-medium outline-none bg-transparent min-w-0" + /> + onUpdateToken(index, tokenAddress)} + /> +
+ +
+
+ ); + })} +
+ ); +} + +// --- Step 3: Transaction Summary --- +function StepTransactionSummary({ + entries, + resolveToken, +}: { + entries: BatchContactEntry[]; + resolveToken: (address: string) => any; +}) { + return ( +
+

+ Please review the information below and confirm to make the transaction. +

+
+ {entries.map((entry, index) => { + const token = resolveToken(entry.tokenAddress); + return ( +
+ Transfer +
+
+ {token.symbol} + + {entry.amount} {token.symbol} + +
+ arrow +
+ avatar + + {entry.contact.name} ({formatAddress(entry.contact.address, { start: 4, end: 4 })}) + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/nextjs/components/modals/EditAccountModal/EditStep.tsx b/packages/nextjs/components/modals/EditAccountModal/EditStep.tsx index 8d39a2a0..3546dcdf 100644 --- a/packages/nextjs/components/modals/EditAccountModal/EditStep.tsx +++ b/packages/nextjs/components/modals/EditAccountModal/EditStep.tsx @@ -24,7 +24,6 @@ const EditStep: React.FC = ({ existingSigners, originalThreshold, loading, - loadingState, onNext, onClose, }) => { @@ -174,11 +173,6 @@ const EditStep: React.FC = ({
- {/* Loading state */} - {loading && loadingState && ( -
{loadingState}
- )} - {/* Content */}
{/* Account Signers Section */} diff --git a/packages/nextjs/components/modals/EditAccountModal/SubmittingStep.tsx b/packages/nextjs/components/modals/EditAccountModal/SubmittingStep.tsx index 21058d66..2044a136 100644 --- a/packages/nextjs/components/modals/EditAccountModal/SubmittingStep.tsx +++ b/packages/nextjs/components/modals/EditAccountModal/SubmittingStep.tsx @@ -2,7 +2,13 @@ import React from "react"; -const SubmittingStep = () => { +interface SubmittingStepProps { + loadingState?: string; + loadingStep?: number; + totalSteps?: number; +} + +const SubmittingStep: React.FC = ({ loadingState = "", loadingStep = 0, totalSteps = 4 }) => { return (
{/* Rocket animation video */} @@ -30,6 +36,20 @@ const SubmittingStep = () => { This may take a few moments. Please don't close this window.

+ + {loadingStep > 0 && totalSteps > 0 && ( +
+
+ Step {loadingStep} of {totalSteps} — {loadingState} +
+
+
+
+
+ )}
); }; diff --git a/packages/nextjs/components/modals/EditAccountModal/index.tsx b/packages/nextjs/components/modals/EditAccountModal/index.tsx index 4de1fe8d..f5fe4c52 100644 --- a/packages/nextjs/components/modals/EditAccountModal/index.tsx +++ b/packages/nextjs/components/modals/EditAccountModal/index.tsx @@ -25,6 +25,8 @@ const EditAccountModal: React.FC = ({ isOpen, onClose }) => { updateThreshold, isLoading: loading, loadingState, + loadingStep, + totalSteps, signers, threshold: originalThreshold, refetchCommitments, @@ -190,7 +192,9 @@ const EditAccountModal: React.FC = ({ isOpen, onClose }) => { /> )} - {step === "submitting" && } + {step === "submitting" && ( + + )} ); }; diff --git a/packages/nextjs/components/modals/ModalLayout.tsx b/packages/nextjs/components/modals/ModalLayout.tsx index f378e129..07eef004 100644 --- a/packages/nextjs/components/modals/ModalLayout.tsx +++ b/packages/nextjs/components/modals/ModalLayout.tsx @@ -54,6 +54,7 @@ const modals: ModalRegistry = { disclaimer: dynamic(() => import("./DisclaimerModal"), { ssr: false }), claimReward: dynamic(() => import("./ClaimRewardModal"), { ssr: false }), questIntro: dynamic(() => import("./QuestIntroModal"), { ssr: false }), + createBatchFromContacts: dynamic(() => import("./CreateBatchFromContactsModal"), { ssr: false }), }; type ModalInstance = { diff --git a/packages/nextjs/components/ui/Spinner.tsx b/packages/nextjs/components/ui/Spinner.tsx new file mode 100644 index 00000000..b1293298 --- /dev/null +++ b/packages/nextjs/components/ui/Spinner.tsx @@ -0,0 +1,7 @@ +interface SpinnerProps { + className?: string; +} + +export function Spinner({ className = "h-4 w-4" }: SpinnerProps) { + return
; +} diff --git a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts index 1d9700c4..e31faa58 100644 --- a/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useBatchTransaction.ts @@ -4,6 +4,7 @@ import { useWalletClient } from "wagmi"; 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 { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -12,9 +13,16 @@ 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, setIsLoading] = useState(false); - const [loadingState, setLoadingState] = useState(""); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = + useStepLoading(BATCH_STEPS); const { data: walletClient } = useWalletClient(); const { secret, commitment: myCommitment } = useIdentityStore(); @@ -22,7 +30,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { const { mutateAsync: createTransaction } = useCreateTransaction(); const { mutateAsync: reserveNonce } = useReserveNonce(); const { generateProof } = useGenerateProof({ - onLoadingStateChange: setLoadingState, + onLoadingStateChange: setStepByLabel, }); const proposeBatch = async (selectedBatchItems: BatchItem[]) => { @@ -42,16 +50,15 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { return; } - setIsLoading(true); - try { const selectedIds = selectedBatchItems.map(item => item.id); // 1. Reserve nonce from backend + startStep(1); const { nonce } = await reserveNonce(metaMultiSigWallet.address); // 2. Get current threshold and commitments - setLoadingState("Preparing batch transaction..."); + startStep(1); const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); // 3. Prepare batch data @@ -79,7 +86,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { const { proof, publicInputs, nullifier, vk } = await generateProof(txHash); // 7. Submit to backend - setLoadingState("Submitting to backend..."); + startStep(4); const result = await createTransaction({ nonce, type: TxType.BATCH, @@ -104,8 +111,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { console.error("Propose batch error:", error); notification.error(formatErrorMessage(error, "Failed to propose batch")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -113,5 +119,7 @@ export const useBatchTransaction = (options?: UseBatchTransactionOptions) => { proposeBatch, isLoading, loadingState, + loadingStep, + totalSteps, }; }; diff --git a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts index 5128c5ea..7d917c47 100644 --- a/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useSignerTransaction.ts @@ -3,6 +3,7 @@ import { SignerData, TxType, encodeAddSigners, encodeRemoveSigners, encodeUpdate 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 { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -10,14 +11,21 @@ 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, setIsLoading] = useState(false); - const [loadingState, setLoadingState] = useState(""); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = + useStepLoading(SIGNER_STEPS); const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); const { generateProof } = useGenerateProof({ - onLoadingStateChange: setLoadingState, + onLoadingStateChange: setStepByLabel, }); const { mutateAsync: createTransaction } = useCreateTransaction(); const { mutateAsync: reserveNonce } = useReserveNonce(); @@ -44,6 +52,8 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { throw new Error("Wallet not connected"); } + startStep(1); + const { nonce } = await reserveNonce(metaMultiSigWallet.address); const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); @@ -56,7 +66,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { const { proof, publicInputs, nullifier, vk } = await generateProof(txHash); - setLoadingState("Submitting to backend..."); + startStep(4); await createTransaction({ nonce, @@ -91,7 +101,6 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { return; } - setIsLoading(true); try { const commitments = newSigners.map(s => s.commitment.trim()); const callData = encodeAddSigners(commitments, newThreshold); @@ -110,8 +119,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { console.error("Failed to add signer:", error); notification.error(formatErrorMessage(error, "Failed to add signer")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -143,7 +151,6 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { return; } - setIsLoading(true); try { const commitments = signersToRemove.map(s => s.commitment.trim()); const callData = encodeRemoveSigners(commitments, adjustedThreshold); @@ -162,8 +169,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { console.error("Failed to remove signer:", error); notification.error(formatErrorMessage(error, "Failed to remove signer")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -178,7 +184,6 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { return; } - setIsLoading(true); try { const callData = encodeUpdateThreshold(newThreshold); await executeSignerTransaction(TxType.SET_THRESHOLD, callData, { @@ -191,8 +196,7 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { console.error("Failed to update threshold:", error); notification.error(formatErrorMessage(error, "Failed to update threshold")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -202,6 +206,8 @@ export const useSignerTransaction = (options?: UseSignerTransactionOptions) => { updateThreshold, isLoading, loadingState, + loadingStep, + totalSteps, signers, threshold, refetchCommitments, diff --git a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts index cda599e7..404688a6 100644 --- a/packages/nextjs/hooks/app/transaction/useTransactionVote.ts +++ b/packages/nextjs/hooks/app/transaction/useTransactionVote.ts @@ -16,6 +16,7 @@ import { useWalletClient } from "wagmi"; 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 { useIdentityStore } from "~~/services/store/useIdentityStore"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -117,9 +118,16 @@ 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, setIsLoading] = useState(false); - const [loadingState, setLoadingState] = useState(""); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset, startLoading } = + useStepLoading(APPROVE_STEPS); const { commitment } = useIdentityStore(); const { data: walletClient } = useWalletClient(); @@ -129,7 +137,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { const { mutateAsync: denyApi } = useDenyTransaction(); const { mutateAsync: executeApi } = useExecuteTransaction(); const { generateProof } = useGenerateProof({ - onLoadingStateChange: setLoadingState, + onLoadingStateChange: setStepByLabel, }); const queryClient = useQueryClient(); @@ -144,7 +152,8 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { return; } - setIsLoading(true); + // Full 4-step flow + startStep(1); try { // 1. Build callData based on tx type const { to, value, callData } = buildTransactionParams(tx); @@ -161,7 +170,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { const proofData = await generateProof(txHash); // 4. Submit to backend - setLoadingState("Submitting to backend..."); + startStep(4); await approveApi({ txId: tx.txId, dto: { @@ -179,8 +188,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { console.error("Approve error:", error); notification.error(formatErrorMessage(error, "Failed to approve")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -195,9 +203,10 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { return; } - setIsLoading(true); + // Single-step fast action + startLoading("Submitting deny vote..."); try { - setLoadingState("Submitting deny vote..."); + // Fast single-step action; keep step 1 await denyApi({ txId: tx.txId, dto: { @@ -211,8 +220,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { console.error("Deny error:", error); notification.error(formatErrorMessage(error, "Failed to deny")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -222,9 +230,9 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { return; } - setIsLoading(true); + // Single-step fast action + startLoading("Executing on-chain..."); try { - setLoadingState("Executing on-chain..."); const result = await executeApi({ txId, dto: { @@ -244,8 +252,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { console.error("Execute error:", error); notification.error(formatErrorMessage(error, "Failed to execute")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -255,5 +262,7 @@ export const useTransactionVote = (options?: UseTransactionVoteOptions) => { execute, isLoading, loadingState, + loadingStep, + totalSteps, }; }; diff --git a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts index 4c6d2b75..b41e2762 100644 --- a/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts +++ b/packages/nextjs/hooks/app/transaction/useTransferTransaction.ts @@ -1,10 +1,11 @@ -import { useState } from "react"; +import { useEffect } from "react"; import { ResolvedToken, TxType, ZERO_ADDRESS, encodeERC20Transfer, parseTokenAmount } from "@polypay/shared"; import { parseEther } from "viem"; import { useWalletClient } from "wagmi"; import { useMetaMultiSigWallet } from "~~/hooks"; import { useCreateTransaction, useReserveNonce } from "~~/hooks/api/useTransaction"; import { useGenerateProof } from "~~/hooks/app/useGenerateProof"; +import { useStepLoading } from "~~/hooks/app/useStepLoading"; import { formatErrorMessage } from "~~/utils/formatError"; import { notification } from "~~/utils/scaffold-eth"; @@ -19,16 +20,23 @@ 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, setIsLoading] = useState(false); - const [loadingState, setLoadingState] = useState(""); + const { isLoading, loadingState, loadingStep, totalSteps, startStep, setStepByLabel, reset } = + useStepLoading(TRANSFER_STEPS); const { data: walletClient } = useWalletClient(); const metaMultiSigWallet = useMetaMultiSigWallet(); const { mutateAsync: createTransaction } = useCreateTransaction(); const { mutateAsync: reserveNonce } = useReserveNonce(); const { generateProof } = useGenerateProof({ - onLoadingStateChange: setLoadingState, + onLoadingStateChange: setStepByLabel, }); const transfer = async ({ recipient, amount, token, contactId }: TransferParams) => { @@ -38,14 +46,13 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) } const isNativeETH = token.address === ZERO_ADDRESS; - - setIsLoading(true); try { // 1. Reserve nonce from backend + startStep(1); const { nonce } = await reserveNonce(metaMultiSigWallet.address); // 2. Get current threshold and commitments - setLoadingState("Preparing transaction..."); + startStep(1); const currentThreshold = await metaMultiSigWallet.read.signaturesRequired(); // 3. Parse amount based on token type @@ -79,7 +86,7 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) const { proof, publicInputs, nullifier, vk } = await generateProof(txHash); // 6. Submit to backend - setLoadingState("Submitting to backend..."); + startStep(4); const result = await createTransaction({ nonce, type: TxType.TRANSFER, @@ -105,8 +112,7 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) console.error("Transfer error:", error); notification.error(formatErrorMessage(error, "Failed to create transfer")); } finally { - setIsLoading(false); - setLoadingState(""); + reset(); } }; @@ -114,5 +120,7 @@ export const useTransferTransaction = (options?: UseTransferTransactionOptions) transfer, isLoading, loadingState, + loadingStep, + totalSteps, }; }; diff --git a/packages/nextjs/hooks/app/useGenerateProof.ts b/packages/nextjs/hooks/app/useGenerateProof.ts index 92d17554..8b003dc5 100644 --- a/packages/nextjs/hooks/app/useGenerateProof.ts +++ b/packages/nextjs/hooks/app/useGenerateProof.ts @@ -75,7 +75,7 @@ export function useGenerateProof(options?: UseGenerateProofOptions) { } // 2. Sign txHash - setLoadingState("Signing transaction..."); + setLoadingState("Waiting for wallet approval..."); const signature = await walletClient.signMessage({ message: { raw: txHash }, }); @@ -115,7 +115,7 @@ export function useGenerateProof(options?: UseGenerateProofOptions) { const execResult = await noir.execute(input); // 7. Generate proof - setLoadingState("Generating ZK proof..."); + setLoadingState("Securing your transaction..."); const plonk = new UltraPlonkBackend(bytecode, { threads: 2 }); const { proof, publicInputs } = await plonk.generateProof(execResult.witness); // const vk = await plonk.getVerificationKey(); diff --git a/packages/nextjs/hooks/app/useStepLoading.ts b/packages/nextjs/hooks/app/useStepLoading.ts new file mode 100644 index 00000000..9285661d --- /dev/null +++ b/packages/nextjs/hooks/app/useStepLoading.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +export interface StepDefinition { + id: number; + label: string; +} + +export function useStepLoading(steps: StepDefinition[]) { + const [isLoading, setIsLoading] = useState(false); + const [loadingState, setLoadingState] = useState(""); + const [loadingStep, setLoadingStep] = useState(0); + + const totalSteps = steps.length; + + const startStep = (id: number) => { + const found = steps.find(s => s.id === id); + if (found) { + setIsLoading(true); + setLoadingStep(found.id); + setLoadingState(found.label); + } + }; + + const setStepByLabel = (label: string) => { + const found = steps.find(s => s.label === label); + if (found) { + setLoadingStep(found.id); + setLoadingState(found.label); + } else { + setLoadingState(label); + } + }; + + const reset = () => { + setIsLoading(false); + setLoadingStep(0); + setLoadingState(""); + }; + + const startLoading = (label: string) => { + setIsLoading(true); + setLoadingState(label); + setLoadingStep(0); + }; + + // Prevent accidental refresh while loading + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isLoading) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isLoading]); + + return { + isLoading, + loadingState, + loadingStep, + totalSteps, + startStep, + setStepByLabel, + reset, + startLoading, + }; +} diff --git a/packages/nextjs/public/contact-book/create-batch.svg b/packages/nextjs/public/contact-book/create-batch.svg new file mode 100644 index 00000000..41fe23b6 --- /dev/null +++ b/packages/nextjs/public/contact-book/create-batch.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/nextjs/public/contact-book/new-contact.svg b/packages/nextjs/public/contact-book/new-contact.svg new file mode 100644 index 00000000..f725cc53 --- /dev/null +++ b/packages/nextjs/public/contact-book/new-contact.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/public/contact-book/new-group.svg b/packages/nextjs/public/contact-book/new-group.svg new file mode 100644 index 00000000..2a5db310 --- /dev/null +++ b/packages/nextjs/public/contact-book/new-group.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/nextjs/public/contact-book/trash.svg b/packages/nextjs/public/contact-book/trash.svg new file mode 100644 index 00000000..ab9f9173 --- /dev/null +++ b/packages/nextjs/public/contact-book/trash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/nextjs/styles/globals.css b/packages/nextjs/styles/globals.css index e99c5c16..b8eb7ff2 100644 --- a/packages/nextjs/styles/globals.css +++ b/packages/nextjs/styles/globals.css @@ -188,6 +188,15 @@ /* margin-bottom: 0.5rem; */ line-height: 1; } + + /* Override browser autofill styles */ + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active { + -webkit-text-fill-color: var(--color-main-black) !important; + transition: 5000s ease-in-out 0s; + } } :root, diff --git a/packages/nextjs/types/modal.ts b/packages/nextjs/types/modal.ts index 32e8698c..08e230aa 100644 --- a/packages/nextjs/types/modal.ts +++ b/packages/nextjs/types/modal.ts @@ -30,4 +30,5 @@ export type ModalName = | "switchAccount" | "disclaimer" | "claimReward" - | "questIntro"; + | "questIntro" + | "createBatchFromContacts"; diff --git a/packages/nextjs/utils/formatError.ts b/packages/nextjs/utils/formatError.ts index 0965a90b..983f18ba 100644 --- a/packages/nextjs/utils/formatError.ts +++ b/packages/nextjs/utils/formatError.ts @@ -7,7 +7,7 @@ type ErrorLike = { const ERROR_PATTERNS: [RegExp, string | null][] = [ [/user rejected|user denied|user refused|ACTION_REJECTED/i, "Transaction was cancelled."], - [/insufficient funds/i, "Insufficient funds for this transaction."], + [/insufficient (funds|balance|account)/i, "Insufficient funds for this transaction."], [/nonce too (high|low)/i, "Transaction conflict. Please try again."], [/gas required exceeds|cannot estimate gas/i, "Transaction failed. Please try again."], [/rate.?limit|429/i, "Too many requests. Please wait a moment."], diff --git a/packages/shared/src/constants/token.ts b/packages/shared/src/constants/token.ts index 2cf7e3ea..645ba263 100644 --- a/packages/shared/src/constants/token.ts +++ b/packages/shared/src/constants/token.ts @@ -39,7 +39,7 @@ export const NATIVE_ETH: Token = { export const ZEN_TOKEN: Token = { addresses: { - [HORIZEN_TESTNET]: "0x4b36cb6E7c257E9aA246122a997be0F7Dc1eFCd1", + [HORIZEN_TESTNET]: "0xb06EC4ce262D8dbDc24Fac87479A49A7DC4cFb87", [HORIZEN_MAINNET]: "0x57da2D504bf8b83Ef304759d9f2648522D7a9280", [BASE_MAINNET]: "0xf43eB8De897Fbc7F2502483B2Bef7Bb9EA179229", [BASE_SEPOLIA]: "0x107fdE93838e3404934877935993782F977324BB",