From 44f74e5e27e848dd424c45ba6a5dce42fecde663 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Wed, 11 Mar 2026 14:54:40 +0700 Subject: [PATCH 1/9] Refactor test --- .../backend/test/e2e/transaction.e2e-spec.ts | 271 +++++++----------- packages/backend/test/jest.setup.ts | 10 +- packages/backend/test/utils/identity.util.ts | 34 +++ .../backend/test/utils/transaction.util.ts | 143 +++++++++ 4 files changed, 290 insertions(+), 168 deletions(-) create mode 100644 packages/backend/test/utils/identity.util.ts create mode 100644 packages/backend/test/utils/transaction.util.ts diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index f2dbb9c8..4440b9a1 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -1,45 +1,39 @@ -import * as request from 'supertest'; import { type Hex, formatEther } from 'viem'; import { 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 { depositToAccount, - getTransactionHash, getAccountBalance, } from '../utils/contract.util'; import { getPrismaService } from '../utils/cleanup.util'; -import { loginUser, getAuthHeader, AuthTokens } from '../utils/auth.util'; +import { loginUser, AuthTokens } from '../utils/auth.util'; +import { TestIdentity, createTestIdentity } from '../utils/identity.util'; +import { + apiCreateAccount, + apiReserveNonce, + apiCreateTransaction, + apiApproveTransaction, + apiExecuteTransaction, + apiGetTransaction, + generateVotePayload, +} from '../utils/transaction.util'; import { - API_ENDPOINTS, CreateAccountDto, TxStatus, TxType, } from '@polypay/shared'; -// Timeout 5 minutes for blockchain calls -jest.setTimeout(600000); +// Timeout 10 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 +41,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 () => { @@ -92,170 +58,143 @@ describe('Transaction E2E', () => { describe('Full transaction flow', () => { it('should complete full flow: create account → create tx → approve → execute', async () => { - const server = getHttpServer(); + console.log('\n=== Transaction E2E: Start ==='); // ============ STEP 1: Create Account ============ - console.log('\n--- Step 1: Create Account ---'); - + console.log('Step 1: Create Account - start'); const dataCreateAccount: CreateAccountDto = { name: 'Test Multi-Sig Account', - signers: [signerDtoA, signerDtoB], + signers: [identityA.signerDto, identityB.signerDto], threshold: 2, chainId: 2651420, }; - 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); + const { address: accountAddress } = await apiCreateAccount( + tokensA.accessToken, + dataCreateAccount, + ); + console.log('Step 1: Create Account - done', { + accountAddress, + }); // ============ STEP 2: Deposit ETH to Account ============ - console.log('\n--- Step 2: Deposit ETH to Account ---'); - + console.log('Step 2: Deposit - start'); const balanceBefore = await getAccountBalance(accountAddress); - console.log(' Balance before:', formatEther(balanceBefore), 'ETH'); - - await depositToAccount(signerA, accountAddress, '0.001'); + await depositToAccount(identityA.signer, accountAddress, '0.0001'); const balanceAfter = await getAccountBalance(accountAddress); - console.log(' Balance after:', formatEther(balanceAfter), 'ETH'); expect(balanceAfter).toBeGreaterThan(balanceBefore); + console.log('Step 2: Deposit - done', { + balanceBefore: formatEther(balanceBefore), + balanceAfter: formatEther(balanceAfter), + }); // ============ 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); + console.log('Step 3.1: Reserve nonce - start'); + const { nonce } = await apiReserveNonce( + tokensA.accessToken, + accountAddress, + ); + console.log('Step 3.1: Reserve nonce - done', { nonce }); // 3.2 Prepare transfer params const recipient = '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; - const value = BigInt('1000000000000000'); // 0.001 ETH + const value = BigInt('100000000000000'); // 0.0001 ETH const callData = '0x' as Hex; - // 3.3 Get txHash from contract - const txHash = await getTransactionHash( + // 3.3 Generate proof for signer A and create transaction + console.log('Step 3.2: Generate proof A - start'); + const votePayloadA = await generateVotePayload( + identityA, accountAddress, BigInt(nonce), recipient, value, callData, ); - console.log(' TxHash from contract:', txHash); + console.log('Step 3.2: Generate proof A - done'); - // 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, - 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); + console.log('Step 3.3: Create transaction - start'); + const { txId } = await apiCreateTransaction(tokensA.accessToken, { + nonce, + type: TxType.TRANSFER, + accountAddress, + to: recipient, + value: value.toString(), + threshold: 2, + creatorCommitment: identityA.commitment, + proof: votePayloadA.proof, + publicInputs: votePayloadA.publicInputs, + nullifier: votePayloadA.nullifier, + }); + console.log('Step 3.3: Create transaction - done', { 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); + console.log('Step 4.1: Get transaction details - start'); + const txDetails = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { + nonce: number; + to: string; + value: string; + }; + console.log('Step 4.1: Get transaction details - done', { + nonce: txDetails.nonce, + to: txDetails.to, + value: txDetails.value, + }); - // 4.2 Get txHash for approve - const txHashForApprove = await getTransactionHash( + // 4.2 Generate proof for signer B and approve + console.log('Step 4.2: Generate proof B - start'); + const votePayloadB = await generateVotePayload( + identityB, accountAddress, - BigInt(getTxResponse.body.nonce), - getTxResponse.body.to as `0x${string}`, - BigInt(getTxResponse.body.value), + BigInt(txDetails.nonce), + txDetails.to as `0x${string}`, + BigInt(txDetails.value), callData, ); + console.log('Step 4.2: Generate proof B - done'); - // 4.3 Generate proof for signer B - console.log(' Generating proof for Signer B...'); - const proofB = await generateTestProof( - signerB, - secretB, - txHashForApprove, - ); - 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'); + console.log('Step 4.3: Approve transaction - start'); + await apiApproveTransaction(tokensB.accessToken, txId, votePayloadB); + console.log('Step 4.3: Approve transaction - done'); // ============ 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); + console.log('Step 5: Execute transaction - start'); + const { txHash } = await apiExecuteTransaction( + tokensA.accessToken, + txId, + ); + expect(txHash).toBeDefined(); + console.log('Step 5: Execute transaction - done', { txHash }); // ============ STEP 6: Verify Final State ============ - console.log('\n--- Step 6: Verify Final State ---'); - + console.log('Step 6: Verify final state - start'); const prisma = getPrismaService(getTestApp()); - const finalTx = await prisma.transaction.findUnique({ - where: { txId: txId }, + const finalTx = (await prisma.transaction.findUnique({ + where: { txId: Number(txId) }, include: { votes: true }, - }); + })) as { + status: TxStatus; + votes: unknown[]; + } | null; expect(finalTx).not.toBeNull(); expect(finalTx!.status).toBe(TxStatus.EXECUTED); expect(finalTx!.votes.length).toBe(2); + console.log('Step 6: Verify final state - done', { + status: finalTx?.status, + votes: finalTx?.votes.length, + }); - console.log('Final verification:'); - console.log(' Status:', finalTx!.status); - console.log(' Vote count:', finalTx!.votes.length); - console.log('\n✅ Full transaction flow completed successfully!'); + console.log('=== Transaction E2E: Done ===\n'); }); }); }); 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/transaction.util.ts b/packages/backend/test/utils/transaction.util.ts new file mode 100644 index 00000000..16efddd6 --- /dev/null +++ b/packages/backend/test/utils/transaction.util.ts @@ -0,0 +1,143 @@ +import * as request from 'supertest'; +import { type Hex } from 'viem'; +import { + API_ENDPOINTS, + CreateAccountDto, + TxType, +} from '@polypay/shared'; +import { getHttpServer } from '../setup'; +import { getAuthHeader } from './auth.util'; +import { getTransactionHash } from './contract.util'; +import { generateTestProof } from './proof.util'; +import { type TestIdentity } from './identity.util'; + +export interface CreateTransactionPayload { + nonce: number; + type: TxType; + accountAddress: `0x${string}`; + to: `0x${string}`; + value: string; + threshold: number; + creatorCommitment: string; + proof: number[]; + publicInputs: string[]; + nullifier: string; +} + +export interface ApproveTransactionPayload { + voterCommitment: string; + proof: number[]; + publicInputs: string[]; + nullifier: string; +} + +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 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 { + voterCommitment: identity.commitment, + proof: proof.proof, + publicInputs: proof.publicInputs, + nullifier: proof.nullifier, + }; +} + From ed3b4a8d9fa4e39ab1dc23c7c9e2c2cb47e7b74f Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Wed, 11 Mar 2026 16:03:57 +0700 Subject: [PATCH 2/9] feat: enhance transaction E2E tests to support multi-asset transfers - Added support for ETH, ZEN, and USDC in transaction flow tests. - Introduced utility functions for ERC20 token transfers and balance checks. - Refactored account funding logic to accommodate multiple asset scenarios. - Updated test descriptions for clarity and consistency. --- .../backend/test/e2e/transaction.e2e-spec.ts | 373 ++++++++++++------ .../backend/test/utils/transaction.util.ts | 88 ++++- 2 files changed, 349 insertions(+), 112 deletions(-) diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index 4440b9a1..b010542e 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -1,4 +1,4 @@ -import { type Hex, formatEther } from 'viem'; +import { type Hex, formatEther, parseEther } from 'viem'; import { setupTestApp, teardownTestApp, @@ -21,13 +21,124 @@ import { apiExecuteTransaction, apiGetTransaction, generateVotePayload, + toTokenAmount, + transferErc20FromSigner, + getErc20Balance, } from '../utils/transaction.util'; import { CreateAccountDto, TxStatus, TxType, + ZEN_TOKEN, + USDC_TOKEN, } from '@polypay/shared'; +const TEST_CHAIN_ID = 2651420; +const TEST_TRANSFER_AMOUNT = '0.0001'; +const TEST_FUND_ETH_AMOUNT = '0.0003'; // 3x transfer amount to cover gas + ERC20 +const TEST_RECIPIENT = + '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; + +type AssetName = 'ETH' | 'ZEN' | 'USDC'; + +interface AssetScenario { + name: AssetName; + isNative: boolean; + tokenAddress: `0x${string}` | null; + decimals: number; +} + +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, + }, +]; + +interface ScenarioAmount { + scenario: AssetScenario; + amountBigInt: bigint; + amountString: string; +} + +async function fundAccountForScenario( + scenario: AssetScenario, + accountAddress: `0x${string}`, + identityA: TestIdentity, +): Promise { + if (scenario.isNative) { + // Native ETH funding via direct deposit + const balanceBefore = await getAccountBalance(accountAddress); + // Fund a bit extra to cover gas for ERC20 transfers as well (3x amount) + 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 { amountBigInt, amountString } = toTokenAmount( + TEST_TRANSFER_AMOUNT, + scenario.decimals, + ); + + const balanceBefore = await getErc20Balance( + accountAddress, + scenario.tokenAddress, + ); + + await transferErc20FromSigner( + identityA.signer, + scenario.tokenAddress, + accountAddress, + amountBigInt, + ); + + const balanceAfter = await getErc20Balance( + accountAddress, + scenario.tokenAddress, + ); + + console.log(`[${scenario.name}] Fund ERC20 - done`, { + tokenAddress: scenario.tokenAddress, + balanceBefore: balanceBefore.toString(), + balanceAfter: balanceAfter.toString(), + }); + + return { + scenario, + amountBigInt, + amountString, + }; +} + // Timeout 10 minutes for blockchain calls jest.setTimeout(1200000); @@ -57,144 +168,184 @@ describe('Transaction E2E', () => { }); describe('Full transaction flow', () => { - it('should complete full flow: create account → create tx → approve → execute', async () => { - console.log('\n=== Transaction E2E: Start ==='); + 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('Step 1: Create Account - start'); + console.log('Phase 1: Create Account - start'); const dataCreateAccount: CreateAccountDto = { - name: 'Test Multi-Sig Account', + name: 'Test Multi-Sig Account (Multi-asset)', signers: [identityA.signerDto, identityB.signerDto], threshold: 2, - chainId: 2651420, + chainId: TEST_CHAIN_ID, }; const { address: accountAddress } = await apiCreateAccount( tokensA.accessToken, dataCreateAccount, ); - console.log('Step 1: Create Account - done', { + console.log('Phase 1: Create Account - done', { accountAddress, }); - // ============ STEP 2: Deposit ETH to Account ============ - console.log('Step 2: Deposit - start'); - const balanceBefore = await getAccountBalance(accountAddress); - await depositToAccount(identityA.signer, accountAddress, '0.0001'); + // ============ STEP 2: Fund account for all scenarios ============ + console.log('Phase 2: Fund account for all scenarios - start'); + const scenarioAmounts: ScenarioAmount[] = []; - const balanceAfter = await getAccountBalance(accountAddress); + 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: funded.amountString, + }); + } + console.log('Phase 2: Fund account for all scenarios - done'); - expect(balanceAfter).toBeGreaterThan(balanceBefore); - console.log('Step 2: Deposit - done', { - balanceBefore: formatEther(balanceBefore), - balanceAfter: formatEther(balanceAfter), - }); + // ============ STEP 3: Create Transactions for all scenarios ============ + console.log('Phase 3: Create transactions - start'); + const createdTxs: { + scenario: AssetScenario; + amount: ScenarioAmount; + txId: string; + }[] = []; - // ============ STEP 3: Create Transaction ============ - // 3.1 Reserve nonce - console.log('Step 3.1: Reserve nonce - start'); - const { nonce } = await apiReserveNonce( - tokensA.accessToken, - accountAddress, - ); - console.log('Step 3.1: Reserve nonce - done', { nonce }); - - // 3.2 Prepare transfer params - const recipient = - '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; - const value = BigInt('100000000000000'); // 0.0001 ETH + const recipient = TEST_RECIPIENT; const callData = '0x' as Hex; - // 3.3 Generate proof for signer A and create transaction - console.log('Step 3.2: Generate proof A - start'); - const votePayloadA = await generateVotePayload( - identityA, - accountAddress, - BigInt(nonce), - recipient, - value, - callData, - ); - console.log('Step 3.2: Generate proof A - done'); + for (const amount of scenarioAmounts) { + console.log(`[${amount.scenario.name}] Create transaction - start`); - console.log('Step 3.3: Create transaction - start'); - const { txId } = await apiCreateTransaction(tokensA.accessToken, { - nonce, - type: TxType.TRANSFER, - accountAddress, - to: recipient, - value: value.toString(), - threshold: 2, - creatorCommitment: identityA.commitment, - proof: votePayloadA.proof, - publicInputs: votePayloadA.publicInputs, - nullifier: votePayloadA.nullifier, - }); - console.log('Step 3.3: Create transaction - done', { txId }); + const { nonce } = await apiReserveNonce( + tokensA.accessToken, + accountAddress, + ); - // ============ STEP 4: Approve Transaction (Signer B) ============ - // 4.1 Get transaction details - console.log('Step 4.1: Get transaction details - start'); - const txDetails = (await apiGetTransaction( - tokensA.accessToken, - txId, - )) as { - nonce: number; - to: string; - value: string; - }; - console.log('Step 4.1: Get transaction details - done', { - nonce: txDetails.nonce, - to: txDetails.to, - value: txDetails.value, - }); + const votePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(nonce), + recipient, + amount.amountBigInt, + callData, + ); - // 4.2 Generate proof for signer B and approve - console.log('Step 4.2: Generate proof B - start'); - const votePayloadB = await generateVotePayload( - identityB, - accountAddress, - BigInt(txDetails.nonce), - txDetails.to as `0x${string}`, - BigInt(txDetails.value), - callData, - ); - console.log('Step 4.2: Generate proof B - done'); + const { txId } = await apiCreateTransaction(tokensA.accessToken, { + nonce, + type: TxType.TRANSFER, + accountAddress, + to: recipient, + value: amount.amountString, + threshold: 2, + creatorCommitment: identityA.commitment, + proof: votePayloadA.proof, + publicInputs: votePayloadA.publicInputs, + nullifier: votePayloadA.nullifier, + ...(amount.scenario.isNative + ? {} + : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), + }); - console.log('Step 4.3: Approve transaction - start'); - await apiApproveTransaction(tokensB.accessToken, txId, votePayloadB); - console.log('Step 4.3: Approve transaction - done'); + createdTxs.push({ scenario: amount.scenario, amount, txId }); - // ============ STEP 5: Execute Transaction ============ - console.log('Step 5: Execute transaction - start'); - const { txHash } = await apiExecuteTransaction( - tokensA.accessToken, - txId, - ); - expect(txHash).toBeDefined(); - console.log('Step 5: Execute transaction - done', { txHash }); + console.log(`[${amount.scenario.name}] Create transaction - done`, { + txId, + }); + } + console.log('Phase 3: Create transactions - done'); + + // ============ STEP 4: Approve all Transactions (Signer B) ============ + console.log('Phase 4: Approve transactions - start'); + for (const { scenario, txId } of createdTxs) { + console.log(`[${scenario.name}] Approve transaction - start`, { txId }); - // ============ STEP 6: Verify Final State ============ - console.log('Step 6: Verify final state - start'); + const txDetails = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { + nonce: number; + to: string; + value: string; + tokenAddress?: string | null; + }; + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + txDetails.to as `0x${string}`, + BigInt(txDetails.value), + callData, + ); + + await apiApproveTransaction(tokensB.accessToken, txId, votePayloadB); + + console.log(`[${scenario.name}] Approve transaction - done`, { + txId, + }); + } + console.log('Phase 4: Approve transactions - done'); + + // ============ STEP 5: Execute all Transactions sequentially ============ + console.log('Phase 5: Execute transactions - start'); + for (const { scenario, txId } of createdTxs) { + console.log(`[${scenario.name}] Execute transaction - start`, { txId }); + + const { txHash } = await apiExecuteTransaction( + tokensA.accessToken, + txId, + ); + expect(txHash).toBeDefined(); + + console.log(`[${scenario.name}] Execute transaction - done`, { + txId, + txHash, + }); + } + console.log('Phase 5: Execute transactions - done'); + + // ============ STEP 6: Verify Final State for each transaction ============ + console.log('Phase 6: Verify final state - start'); const prisma = getPrismaService(getTestApp()); - const finalTx = (await prisma.transaction.findUnique({ - where: { txId: Number(txId) }, - include: { votes: true }, - })) as { - status: TxStatus; - votes: unknown[]; - } | null; - - expect(finalTx).not.toBeNull(); - expect(finalTx!.status).toBe(TxStatus.EXECUTED); - expect(finalTx!.votes.length).toBe(2); - console.log('Step 6: Verify final state - done', { - status: finalTx?.status, - votes: finalTx?.votes.length, - }); + for (const { scenario, amount, txId } of createdTxs) { + const finalTx = (await prisma.transaction.findUnique({ + where: { txId: Number(txId) }, + include: { votes: true }, + })) 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 (scenario.isNative) { + expect(finalTx!.tokenAddress).toBeNull(); + } else { + expect(finalTx!.tokenAddress?.toLowerCase()).toBe( + (scenario.tokenAddress as string).toLowerCase(), + ); + } + expect(finalTx!.value).toBe(amount.amountString); + + console.log(`[${scenario.name}] Final verification - done`, { + status: finalTx?.status, + votes: finalTx?.votes.length, + tokenAddress: finalTx?.tokenAddress, + value: finalTx?.value, + }); + } - console.log('=== Transaction E2E: Done ===\n'); + console.log('Phase 6: Verify final state - done'); + console.log('=== Multi-asset Transaction E2E: Done ===\n'); }); }); }); diff --git a/packages/backend/test/utils/transaction.util.ts b/packages/backend/test/utils/transaction.util.ts index 16efddd6..202bffaa 100644 --- a/packages/backend/test/utils/transaction.util.ts +++ b/packages/backend/test/utils/transaction.util.ts @@ -5,11 +5,14 @@ import { CreateAccountDto, TxType, } from '@polypay/shared'; +import { parseTokenAmount } from '@polypay/shared'; import { getHttpServer } from '../setup'; import { getAuthHeader } from './auth.util'; -import { getTransactionHash } from './contract.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; @@ -31,6 +34,11 @@ export interface ApproveTransactionPayload { nullifier: string; } +export interface ParsedTokenAmount { + amountString: string; + amountBigInt: bigint; +} + export async function apiCreateAccount( accessToken: string, dto: CreateAccountDto, @@ -141,3 +149,81 @@ export async function generateVotePayload( }; } +/** + * 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; +} + + From 108b263f2fb0dc9140ec2ab69c9209a3bea826d1 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 08:42:43 +0700 Subject: [PATCH 3/9] Add batch to e2e flow --- .../backend/test/e2e/transaction.e2e-spec.ts | 257 ++++++++++++++---- .../backend/test/utils/transaction.util.ts | 23 ++ 2 files changed, 222 insertions(+), 58 deletions(-) diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index b010542e..8e5bc964 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -15,6 +15,7 @@ import { loginUser, AuthTokens } from '../utils/auth.util'; import { TestIdentity, createTestIdentity } from '../utils/identity.util'; import { apiCreateAccount, + apiCreateBatchItem, apiReserveNonce, apiCreateTransaction, apiApproveTransaction, @@ -27,15 +28,19 @@ import { } from '../utils/transaction.util'; import { CreateAccountDto, + encodeERC20Transfer, + encodeBatchTransferMulti, TxStatus, TxType, ZEN_TOKEN, USDC_TOKEN, + ZERO_ADDRESS, } from '@polypay/shared'; const TEST_CHAIN_ID = 2651420; -const TEST_TRANSFER_AMOUNT = '0.0001'; -const TEST_FUND_ETH_AMOUNT = '0.0003'; // 3x transfer amount to cover gas + ERC20 +const TEST_TRANSFER_AMOUNT = '0.0001'; // per tx (single + batch item) +const TEST_FUND_ETH_AMOUNT = '0.0004'; // 2x transfer (0.0002) + gas for 4 txs +const TEST_FUND_ERC20_MULTIPLIER = 2; // fund 2x so: 1x single tx + 1x batch item const TEST_RECIPIENT = '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; @@ -75,15 +80,17 @@ interface ScenarioAmount { amountString: string; } +/** + * 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. + */ async function fundAccountForScenario( scenario: AssetScenario, accountAddress: `0x${string}`, identityA: TestIdentity, ): Promise { if (scenario.isNative) { - // Native ETH funding via direct deposit const balanceBefore = await getAccountBalance(accountAddress); - // Fund a bit extra to cover gas for ERC20 transfers as well (3x amount) await depositToAccount(identityA.signer, accountAddress, TEST_FUND_ETH_AMOUNT); const balanceAfter = await getAccountBalance(accountAddress); @@ -104,6 +111,11 @@ async function fundAccountForScenario( throw new Error(`Token address is required for ERC20 scenario: ${scenario.name}`); } + // Fund 2x so: 1x single transfer + 1x batch transfer + 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, @@ -118,7 +130,7 @@ async function fundAccountForScenario( identityA.signer, scenario.tokenAddress, accountAddress, - amountBigInt, + fundAmount, ); const balanceAfter = await getErc20Balance( @@ -126,7 +138,7 @@ async function fundAccountForScenario( scenario.tokenAddress, ); - console.log(`[${scenario.name}] Fund ERC20 - done`, { + console.log(`[${scenario.name}] Fund ERC20 (2x) - done`, { tokenAddress: scenario.tokenAddress, balanceBefore: balanceBefore.toString(), balanceAfter: balanceAfter.toString(), @@ -206,31 +218,43 @@ describe('Transaction E2E', () => { } console.log('Phase 2: Fund account for all scenarios - done'); - // ============ STEP 3: Create Transactions for all scenarios ============ + // ============ STEP 3: Create Transactions (3 single + 1 batch) ============ console.log('Phase 3: Create transactions - start'); - const createdTxs: { - scenario: AssetScenario; - amount: ScenarioAmount; - txId: string; - }[] = []; + type CreatedTx = + | { + kind: 'single'; + scenario: AssetScenario; + amount: ScenarioAmount; + txId: string; + } + | { kind: 'batch'; txId: string }; + const createdTxs: CreatedTx[] = []; const recipient = TEST_RECIPIENT; - const callData = '0x' as Hex; + // 3 single transfers (ETH, ZEN, USDC) for (const amount of scenarioAmounts) { - console.log(`[${amount.scenario.name}] Create transaction - start`); + console.log(`[${amount.scenario.name}] Create single transaction - start`); const { nonce } = await apiReserveNonce( tokensA.accessToken, accountAddress, ); + const to = amount.scenario.isNative + ? (recipient as `0x${string}`) + : (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); + const votePayloadA = await generateVotePayload( identityA, accountAddress, BigInt(nonce), - recipient, - amount.amountBigInt, + to, + value, callData, ); @@ -250,50 +274,167 @@ describe('Transaction E2E', () => { : { tokenAddress: amount.scenario.tokenAddress as `0x${string}` }), }); - createdTxs.push({ scenario: amount.scenario, amount, txId }); - - console.log(`[${amount.scenario.name}] Create transaction - done`, { + createdTxs.push({ kind: 'single', scenario: amount.scenario, amount, txId }); + console.log(`[${amount.scenario.name}] Create single transaction - done`, { txId, }); } + + // 1 batch tx (ETH + ZEN + USDC, same amounts) + 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 batchRecipient = TEST_RECIPIENT; + const batchRecipients = [ + batchRecipient as `0x${string}`, + batchRecipient as `0x${string}`, + batchRecipient as `0x${string}`, + ]; + const batchAmounts = scenarioAmounts.map((a) => a.amountBigInt); + const batchTokenAddresses = scenarioAmounts.map((a) => + a.scenario.tokenAddress ?? (ZERO_ADDRESS as `0x${string}`), + ); + const batchCallData = encodeBatchTransferMulti( + batchRecipients, + batchAmounts, + batchTokenAddresses as string[], + ) as Hex; + + const { nonce: batchNonce } = await apiReserveNonce( + tokensA.accessToken, + accountAddress, + ); + + const batchVotePayloadA = await generateVotePayload( + identityA, + accountAddress, + BigInt(batchNonce), + accountAddress as `0x${string}`, + 0n, + batchCallData, + ); + + const { txId: batchTxId } = await apiCreateTransaction( + tokensA.accessToken, + { + nonce: batchNonce, + type: TxType.BATCH, + accountAddress, + to: accountAddress, + value: '0', + threshold: 2, + creatorCommitment: identityA.commitment, + 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 Transactions (Signer B) ============ + // ============ STEP 4: Approve all 4 Transactions (Signer B) ============ console.log('Phase 4: Approve transactions - start'); - for (const { scenario, txId } of createdTxs) { - console.log(`[${scenario.name}] Approve transaction - start`, { txId }); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = entry.kind === 'single' ? entry.scenario.name : 'batch'; + console.log(`[${label}] Approve transaction - start`, { txId }); const txDetails = (await apiGetTransaction( tokensA.accessToken, txId, )) as { nonce: number; - to: string; - value: string; + to?: string; + value?: string; tokenAddress?: string | null; + batchData?: string; }; - const votePayloadB = await generateVotePayload( - identityB, - accountAddress, - BigInt(txDetails.nonce), - txDetails.to as `0x${string}`, - BigInt(txDetails.value), - callData, - ); - - await apiApproveTransaction(tokensB.accessToken, txId, votePayloadB); + if (entry.kind === 'batch') { + const parsedBatch = JSON.parse(txDetails.batchData!) as Array<{ + recipient: string; + amount: string; + tokenAddress?: string | null; + }>; + const approveRecipients = parsedBatch.map( + (p) => p.recipient as `0x${string}`, + ); + const approveAmounts = parsedBatch.map((p) => BigInt(p.amount)); + const approveTokenAddresses = parsedBatch.map( + (p) => p.tokenAddress || ZERO_ADDRESS, + ); + const callDataApprove = encodeBatchTransferMulti( + approveRecipients, + approveAmounts, + approveTokenAddresses, + ) as Hex; + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + accountAddress as `0x${string}`, + 0n, + callDataApprove, + ); + await apiApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } else { + const toApprove = txDetails.tokenAddress + ? (txDetails.tokenAddress as `0x${string}`) + : (txDetails.to as `0x${string}`); + const valueApprove = txDetails.tokenAddress + ? 0n + : BigInt(txDetails.value!); + const callDataApprove = txDetails.tokenAddress + ? (encodeERC20Transfer( + txDetails.to!, + BigInt(txDetails.value!), + ) as Hex) + : ('0x' as Hex); + + const votePayloadB = await generateVotePayload( + identityB, + accountAddress, + BigInt(txDetails.nonce), + toApprove, + valueApprove, + callDataApprove, + ); + await apiApproveTransaction( + tokensB.accessToken, + txId, + votePayloadB, + ); + } - console.log(`[${scenario.name}] Approve transaction - done`, { - txId, - }); + console.log(`[${label}] Approve transaction - done`, { txId }); } console.log('Phase 4: Approve transactions - done'); - // ============ STEP 5: Execute all Transactions sequentially ============ + // ============ STEP 5: Execute all 4 Transactions sequentially ============ console.log('Phase 5: Execute transactions - start'); - for (const { scenario, txId } of createdTxs) { - console.log(`[${scenario.name}] Execute transaction - start`, { txId }); + for (const entry of createdTxs) { + const txId = entry.txId; + const label = entry.kind === 'single' ? entry.scenario.name : 'batch'; + console.log(`[${label}] Execute transaction - start`, { txId }); const { txHash } = await apiExecuteTransaction( tokensA.accessToken, @@ -301,46 +442,46 @@ describe('Transaction E2E', () => { ); expect(txHash).toBeDefined(); - console.log(`[${scenario.name}] Execute transaction - done`, { - txId, - txHash, - }); + console.log(`[${label}] Execute transaction - done`, { txId, txHash }); } console.log('Phase 5: Execute transactions - done'); - // ============ STEP 6: Verify Final State for each transaction ============ + // ============ STEP 6: Verify Final State for all 4 transactions ============ console.log('Phase 6: Verify final state - start'); const prisma = getPrismaService(getTestApp()); - for (const { scenario, amount, txId } of createdTxs) { + for (const entry of createdTxs) { + const txId = entry.txId; + const label = entry.kind === 'single' ? entry.scenario.name : 'batch'; + const finalTx = (await prisma.transaction.findUnique({ where: { txId: Number(txId) }, include: { votes: true }, })) as { status: TxStatus; votes: unknown[]; - tokenAddress: string | null; - value: string; + tokenAddress?: string | null; + value?: string; } | null; expect(finalTx).not.toBeNull(); expect(finalTx!.status).toBe(TxStatus.EXECUTED); expect(finalTx!.votes.length).toBe(2); - if (scenario.isNative) { - expect(finalTx!.tokenAddress).toBeNull(); - } else { - expect(finalTx!.tokenAddress?.toLowerCase()).toBe( - (scenario.tokenAddress as string).toLowerCase(), - ); + 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); } - expect(finalTx!.value).toBe(amount.amountString); - console.log(`[${scenario.name}] Final verification - done`, { + console.log(`[${label}] Final verification - done`, { status: finalTx?.status, votes: finalTx?.votes.length, - tokenAddress: finalTx?.tokenAddress, - value: finalTx?.value, }); } diff --git a/packages/backend/test/utils/transaction.util.ts b/packages/backend/test/utils/transaction.util.ts index 202bffaa..97f4845f 100644 --- a/packages/backend/test/utils/transaction.util.ts +++ b/packages/backend/test/utils/transaction.util.ts @@ -25,6 +25,8 @@ export interface CreateTransactionPayload { proof: number[]; publicInputs: string[]; nullifier: string; + /** For TxType.BATCH: IDs from apiCreateBatchItem (order = execution order) */ + batchItemIds?: string[]; } export interface ApproveTransactionPayload { @@ -123,6 +125,27 @@ export async function apiGetTransaction(accessToken: string, txId: string) { 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}`, From 840a72aab274a5372326f2fc33cab6f144440737 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 09:16:26 +0700 Subject: [PATCH 4/9] Refactor test local --- .../backend/test/e2e/transaction.e2e-spec.ts | 176 +++++++++++------- 1 file changed, 106 insertions(+), 70 deletions(-) diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index 8e5bc964..cd7b0f44 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -39,10 +39,11 @@ import { const TEST_CHAIN_ID = 2651420; const TEST_TRANSFER_AMOUNT = '0.0001'; // per tx (single + batch item) -const TEST_FUND_ETH_AMOUNT = '0.0004'; // 2x transfer (0.0002) + gas for 4 txs +const TEST_FUND_ETH_AMOUNT = '0.0003'; // 2x transfer (0.0002) + gas for 4 txs const TEST_FUND_ERC20_MULTIPLIER = 2; // fund 2x so: 1x single tx + 1x batch item const TEST_RECIPIENT = '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; +const TEST_THRESHOLD = 2; type AssetName = 'ETH' | 'ZEN' | 'USDC'; @@ -80,6 +81,90 @@ interface ScenarioAmount { amountString: string; } +type CreatedTx = + | { + kind: 'single'; + scenario: AssetScenario; + amount: ScenarioAmount; + txId: string; + } + | { kind: 'batch'; txId: string }; + +function getCreatedTxLabel(entry: CreatedTx): string { + return entry.kind === 'single' ? entry.scenario.name : 'batch'; +} + +/** Match frontend: ETH = (recipient, value, 0x); ERC20 = (tokenAddress, 0, encodeERC20Transfer) */ +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). */ +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 }; +} + +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; +} + +interface ParsedBatchItem { + recipient: string; + amount: string; + tokenAddress?: string | null; +} + +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. @@ -151,7 +236,7 @@ async function fundAccountForScenario( }; } -// Timeout 10 minutes for blockchain calls +// Timeout 20 minutes for blockchain calls jest.setTimeout(1200000); describe('Transaction E2E', () => { @@ -188,7 +273,7 @@ describe('Transaction E2E', () => { const dataCreateAccount: CreateAccountDto = { name: 'Test Multi-Sig Account (Multi-asset)', signers: [identityA.signerDto, identityB.signerDto], - threshold: 2, + threshold: TEST_THRESHOLD, chainId: TEST_CHAIN_ID, }; @@ -220,18 +305,8 @@ describe('Transaction E2E', () => { // ============ STEP 3: Create Transactions (3 single + 1 batch) ============ console.log('Phase 3: Create transactions - start'); - type CreatedTx = - | { - kind: 'single'; - scenario: AssetScenario; - amount: ScenarioAmount; - txId: string; - } - | { kind: 'batch'; txId: string }; const createdTxs: CreatedTx[] = []; - const recipient = TEST_RECIPIENT; - // 3 single transfers (ETH, ZEN, USDC) for (const amount of scenarioAmounts) { console.log(`[${amount.scenario.name}] Create single transaction - start`); @@ -241,13 +316,10 @@ describe('Transaction E2E', () => { accountAddress, ); - const to = amount.scenario.isNative - ? (recipient as `0x${string}`) - : (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); + const { to, value, callData } = buildSingleTransferParams( + amount, + TEST_RECIPIENT, + ); const votePayloadA = await generateVotePayload( identityA, @@ -262,9 +334,9 @@ describe('Transaction E2E', () => { nonce, type: TxType.TRANSFER, accountAddress, - to: recipient, + to: TEST_RECIPIENT, value: amount.amountString, - threshold: 2, + threshold: TEST_THRESHOLD, creatorCommitment: identityA.commitment, proof: votePayloadA.proof, publicInputs: votePayloadA.publicInputs, @@ -295,21 +367,7 @@ describe('Transaction E2E', () => { } console.log('Batch: Create batch items - done', { batchItemIds }); - const batchRecipient = TEST_RECIPIENT; - const batchRecipients = [ - batchRecipient as `0x${string}`, - batchRecipient as `0x${string}`, - batchRecipient as `0x${string}`, - ]; - const batchAmounts = scenarioAmounts.map((a) => a.amountBigInt); - const batchTokenAddresses = scenarioAmounts.map((a) => - a.scenario.tokenAddress ?? (ZERO_ADDRESS as `0x${string}`), - ); - const batchCallData = encodeBatchTransferMulti( - batchRecipients, - batchAmounts, - batchTokenAddresses as string[], - ) as Hex; + const batchCallData = buildBatchCallData(scenarioAmounts, TEST_RECIPIENT); const { nonce: batchNonce } = await apiReserveNonce( tokensA.accessToken, @@ -333,7 +391,7 @@ describe('Transaction E2E', () => { accountAddress, to: accountAddress, value: '0', - threshold: 2, + threshold: TEST_THRESHOLD, creatorCommitment: identityA.commitment, proof: batchVotePayloadA.proof, publicInputs: batchVotePayloadA.publicInputs, @@ -350,7 +408,7 @@ describe('Transaction E2E', () => { console.log('Phase 4: Approve transactions - start'); for (const entry of createdTxs) { const txId = entry.txId; - const label = entry.kind === 'single' ? entry.scenario.name : 'batch'; + const label = getCreatedTxLabel(entry); console.log(`[${label}] Approve transaction - start`, { txId }); const txDetails = (await apiGetTransaction( @@ -365,23 +423,11 @@ describe('Transaction E2E', () => { }; if (entry.kind === 'batch') { - const parsedBatch = JSON.parse(txDetails.batchData!) as Array<{ - recipient: string; - amount: string; - tokenAddress?: string | null; - }>; - const approveRecipients = parsedBatch.map( - (p) => p.recipient as `0x${string}`, - ); - const approveAmounts = parsedBatch.map((p) => BigInt(p.amount)); - const approveTokenAddresses = parsedBatch.map( - (p) => p.tokenAddress || ZERO_ADDRESS, - ); - const callDataApprove = encodeBatchTransferMulti( - approveRecipients, - approveAmounts, - approveTokenAddresses, - ) as Hex; + 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, @@ -397,18 +443,8 @@ describe('Transaction E2E', () => { votePayloadB, ); } else { - const toApprove = txDetails.tokenAddress - ? (txDetails.tokenAddress as `0x${string}`) - : (txDetails.to as `0x${string}`); - const valueApprove = txDetails.tokenAddress - ? 0n - : BigInt(txDetails.value!); - const callDataApprove = txDetails.tokenAddress - ? (encodeERC20Transfer( - txDetails.to!, - BigInt(txDetails.value!), - ) as Hex) - : ('0x' as Hex); + const { to: toApprove, value: valueApprove, callData: callDataApprove } = + buildSingleApproveParams(txDetails); const votePayloadB = await generateVotePayload( identityB, @@ -433,7 +469,7 @@ describe('Transaction E2E', () => { console.log('Phase 5: Execute transactions - start'); for (const entry of createdTxs) { const txId = entry.txId; - const label = entry.kind === 'single' ? entry.scenario.name : 'batch'; + const label = getCreatedTxLabel(entry); console.log(`[${label}] Execute transaction - start`, { txId }); const { txHash } = await apiExecuteTransaction( @@ -452,7 +488,7 @@ describe('Transaction E2E', () => { for (const entry of createdTxs) { const txId = entry.txId; - const label = entry.kind === 'single' ? entry.scenario.name : 'batch'; + const label = getCreatedTxLabel(entry); const finalTx = (await prisma.transaction.findUnique({ where: { txId: Number(txId) }, From fcb037d456fd47f73dfd3ed291a7f13ddf37bb71 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 09:50:54 +0700 Subject: [PATCH 5/9] Change log for human read --- packages/backend/test/e2e/transaction.e2e-spec.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index cd7b0f44..81adad09 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -30,6 +30,7 @@ import { CreateAccountDto, encodeERC20Transfer, encodeBatchTransferMulti, + formatTokenAmount, TxStatus, TxType, ZEN_TOKEN, @@ -225,8 +226,14 @@ async function fundAccountForScenario( console.log(`[${scenario.name}] Fund ERC20 (2x) - done`, { tokenAddress: scenario.tokenAddress, - balanceBefore: balanceBefore.toString(), - balanceAfter: balanceAfter.toString(), + balanceBefore: formatTokenAmount( + balanceBefore.toString(), + scenario.decimals, + ), + balanceAfter: formatTokenAmount( + balanceAfter.toString(), + scenario.decimals, + ), }); return { @@ -298,7 +305,7 @@ describe('Transaction E2E', () => { ); scenarioAmounts.push(funded); console.log(`[${scenario.name}] Funding recorded`, { - amount: funded.amountString, + amount: TEST_TRANSFER_AMOUNT, }); } console.log('Phase 2: Fund account for all scenarios - done'); From 441bb71a19bb6d632b221f0cf89e67a7f530e209 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 10:01:52 +0700 Subject: [PATCH 6/9] clean up unused code --- packages/backend/test/e2e/transaction.e2e-spec.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index 81adad09..e8fae260 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -3,14 +3,12 @@ import { setupTestApp, teardownTestApp, resetDatabase, - getTestApp, } from '../setup'; import { getSignerA, getSignerB } from '../fixtures/test-users'; import { depositToAccount, getAccountBalance, } from '../utils/contract.util'; -import { getPrismaService } from '../utils/cleanup.util'; import { loginUser, AuthTokens } from '../utils/auth.util'; import { TestIdentity, createTestIdentity } from '../utils/identity.util'; import { @@ -491,16 +489,15 @@ describe('Transaction E2E', () => { // ============ STEP 6: Verify Final State for all 4 transactions ============ console.log('Phase 6: Verify final state - start'); - const prisma = getPrismaService(getTestApp()); for (const entry of createdTxs) { const txId = entry.txId; const label = getCreatedTxLabel(entry); - const finalTx = (await prisma.transaction.findUnique({ - where: { txId: Number(txId) }, - include: { votes: true }, - })) as { + const finalTx = (await apiGetTransaction( + tokensA.accessToken, + txId, + )) as { status: TxStatus; votes: unknown[]; tokenAddress?: string | null; From 34ffa546bda60d9468b6c8e89f64d07efe80a24d Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 13:20:27 +0700 Subject: [PATCH 7/9] Test for staging --- packages/backend/.env.example | 5 + packages/backend/package.json | 1 + .../backend/test/e2e/transaction.e2e-spec.ts | 5 +- .../test/e2e/transaction.staging.e2e-spec.ts | 550 ++++++++++++++++++ packages/backend/test/jest-e2e.json | 2 +- packages/backend/test/jest-staging-e2e.json | 19 + .../backend/test/utils/staging-api.util.ts | 197 +++++++ .../backend/test/utils/transaction.util.ts | 4 +- 8 files changed, 776 insertions(+), 7 deletions(-) create mode 100644 packages/backend/test/e2e/transaction.staging.e2e-spec.ts create mode 100644 packages/backend/test/jest-staging-e2e.json create mode 100644 packages/backend/test/utils/staging-api.util.ts 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 e8fae260..8af0f43e 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -275,8 +275,9 @@ describe('Transaction E2E', () => { // ============ 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 (Multi-asset)', + name: `Multi-Sig Account ${createdAt}`, signers: [identityA.signerDto, identityB.signerDto], threshold: TEST_THRESHOLD, chainId: TEST_CHAIN_ID, @@ -342,7 +343,6 @@ describe('Transaction E2E', () => { to: TEST_RECIPIENT, value: amount.amountString, threshold: TEST_THRESHOLD, - creatorCommitment: identityA.commitment, proof: votePayloadA.proof, publicInputs: votePayloadA.publicInputs, nullifier: votePayloadA.nullifier, @@ -397,7 +397,6 @@ describe('Transaction E2E', () => { to: accountAddress, value: '0', threshold: TEST_THRESHOLD, - creatorCommitment: identityA.commitment, proof: batchVotePayloadA.proof, publicInputs: batchVotePayloadA.publicInputs, nullifier: batchVotePayloadA.nullifier, 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..62d1d9bf --- /dev/null +++ b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts @@ -0,0 +1,550 @@ +import { type Hex, formatEther, parseEther } from 'viem'; +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, + ZEN_TOKEN, + USDC_TOKEN, + ZERO_ADDRESS, + encodeERC20Transfer, + encodeBatchTransferMulti, + formatTokenAmount, +} from '@polypay/shared'; +import { + toTokenAmount, + transferErc20FromSigner, + getErc20Balance, + generateVotePayload, +} from '../utils/transaction.util'; +import { + depositToAccount, + getAccountBalance, +} from '../utils/contract.util'; + +const TEST_CHAIN_ID = 2651420; +const TEST_TRANSFER_AMOUNT = '0.0001'; +const TEST_FUND_ETH_AMOUNT = '0.0003'; +const TEST_FUND_ERC20_MULTIPLIER = 2; +const TEST_RECIPIENT = + '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; +const TEST_THRESHOLD = 2; + +type AssetName = 'ETH' | 'ZEN' | 'USDC'; + +interface AssetScenario { + name: AssetName; + isNative: boolean; + tokenAddress: `0x${string}` | null; + decimals: number; +} + +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, + }, +]; + +interface ScenarioAmount { + scenario: AssetScenario; + amountBigInt: bigint; + amountString: string; +} + +type CreatedTx = + | { + kind: 'single'; + scenario: AssetScenario; + amount: ScenarioAmount; + txId: string; + } + | { kind: 'batch'; txId: string }; + +function getCreatedTxLabel(entry: CreatedTx): string { + return entry.kind === 'single' ? entry.scenario.name : 'batch'; +} + +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 }; +} + +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; +} + +interface ParsedBatchItem { + recipient: string; + amount: string; + tokenAddress?: string | null; +} + +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). + */ +async function stagingFundAccountForScenario( + 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, + }; +} + +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 stagingFundAccountForScenario( + 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 } = + (() => { + 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 }; + })(); + + 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/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 index 97f4845f..695846aa 100644 --- a/packages/backend/test/utils/transaction.util.ts +++ b/packages/backend/test/utils/transaction.util.ts @@ -21,7 +21,7 @@ export interface CreateTransactionPayload { to: `0x${string}`; value: string; threshold: number; - creatorCommitment: string; + tokenAddress?: string | null; proof: number[]; publicInputs: string[]; nullifier: string; @@ -30,7 +30,6 @@ export interface CreateTransactionPayload { } export interface ApproveTransactionPayload { - voterCommitment: string; proof: number[]; publicInputs: string[]; nullifier: string; @@ -165,7 +164,6 @@ export async function generateVotePayload( const proof = await generateTestProof(identity.signer, identity.secret, txHash); return { - voterCommitment: identity.commitment, proof: proof.proof, publicInputs: proof.publicInputs, nullifier: proof.nullifier, From d330598fc8368b264f0a358c47fae59def8710cc Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 13:53:05 +0700 Subject: [PATCH 8/9] Change address ZEN token of horizen testnet --- packages/shared/src/constants/token.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 4c2f432b3ec75ebf4ebf78ee96f75b719bd70d61 Mon Sep 17 00:00:00 2001 From: BoHsuu Date: Thu, 12 Mar 2026 15:20:01 +0700 Subject: [PATCH 9/9] Refactor for DRY --- .../backend/test/e2e/transaction.e2e-spec.ts | 241 ++--------------- .../test/e2e/transaction.staging.e2e-spec.ts | 243 ++---------------- .../test/utils/multi-asset-flow.shared.ts | 231 +++++++++++++++++ 3 files changed, 267 insertions(+), 448 deletions(-) create mode 100644 packages/backend/test/utils/multi-asset-flow.shared.ts diff --git a/packages/backend/test/e2e/transaction.e2e-spec.ts b/packages/backend/test/e2e/transaction.e2e-spec.ts index 8af0f43e..c5b6b2e1 100644 --- a/packages/backend/test/e2e/transaction.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.e2e-spec.ts @@ -1,14 +1,9 @@ -import { type Hex, formatEther, parseEther } from 'viem'; import { + resetDatabase, setupTestApp, teardownTestApp, - resetDatabase, } from '../setup'; import { getSignerA, getSignerB } from '../fixtures/test-users'; -import { - depositToAccount, - getAccountBalance, -} from '../utils/contract.util'; import { loginUser, AuthTokens } from '../utils/auth.util'; import { TestIdentity, createTestIdentity } from '../utils/identity.util'; import { @@ -20,226 +15,24 @@ import { apiExecuteTransaction, apiGetTransaction, generateVotePayload, - toTokenAmount, - transferErc20FromSigner, - getErc20Balance, } from '../utils/transaction.util'; +import { CreateAccountDto, TxStatus, TxType } from '@polypay/shared'; import { - CreateAccountDto, - encodeERC20Transfer, - encodeBatchTransferMulti, - formatTokenAmount, - TxStatus, - TxType, - ZEN_TOKEN, - USDC_TOKEN, - ZERO_ADDRESS, -} from '@polypay/shared'; - -const TEST_CHAIN_ID = 2651420; -const TEST_TRANSFER_AMOUNT = '0.0001'; // per tx (single + batch item) -const TEST_FUND_ETH_AMOUNT = '0.0003'; // 2x transfer (0.0002) + gas for 4 txs -const TEST_FUND_ERC20_MULTIPLIER = 2; // fund 2x so: 1x single tx + 1x batch item -const TEST_RECIPIENT = - '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; -const TEST_THRESHOLD = 2; - -type AssetName = 'ETH' | 'ZEN' | 'USDC'; - -interface AssetScenario { - name: AssetName; - isNative: boolean; - tokenAddress: `0x${string}` | null; - decimals: number; -} - -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, - }, -]; - -interface ScenarioAmount { - scenario: AssetScenario; - amountBigInt: bigint; - amountString: string; -} - -type CreatedTx = - | { - kind: 'single'; - scenario: AssetScenario; - amount: ScenarioAmount; - txId: string; - } - | { kind: 'batch'; txId: string }; - -function getCreatedTxLabel(entry: CreatedTx): string { - return entry.kind === 'single' ? entry.scenario.name : 'batch'; -} - -/** Match frontend: ETH = (recipient, value, 0x); ERC20 = (tokenAddress, 0, encodeERC20Transfer) */ -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). */ -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 }; -} - -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; -} - -interface ParsedBatchItem { - recipient: string; - amount: string; - tokenAddress?: string | null; -} - -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. - */ -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}`); - } - - // Fund 2x so: 1x single transfer + 1x batch transfer - 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, - }; -} + 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); diff --git a/packages/backend/test/e2e/transaction.staging.e2e-spec.ts b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts index 62d1d9bf..472f4e3c 100644 --- a/packages/backend/test/e2e/transaction.staging.e2e-spec.ts +++ b/packages/backend/test/e2e/transaction.staging.e2e-spec.ts @@ -1,4 +1,3 @@ -import { type Hex, formatEther, parseEther } from 'viem'; import { getSignerA, getSignerB } from '../fixtures/test-users'; import { TestIdentity, createTestIdentity } from '../utils/identity.util'; import { @@ -11,215 +10,24 @@ import { stagingGetTransaction, stagingCreateBatchItem, } from '../utils/staging-api.util'; +import { CreateAccountDto, TxStatus, TxType } from '@polypay/shared'; +import { generateVotePayload } from '../utils/transaction.util'; import { - CreateAccountDto, - TxStatus, - TxType, - ZEN_TOKEN, - USDC_TOKEN, - ZERO_ADDRESS, - encodeERC20Transfer, - encodeBatchTransferMulti, - formatTokenAmount, -} from '@polypay/shared'; -import { - toTokenAmount, - transferErc20FromSigner, - getErc20Balance, - generateVotePayload, -} from '../utils/transaction.util'; -import { - depositToAccount, - getAccountBalance, -} from '../utils/contract.util'; - -const TEST_CHAIN_ID = 2651420; -const TEST_TRANSFER_AMOUNT = '0.0001'; -const TEST_FUND_ETH_AMOUNT = '0.0003'; -const TEST_FUND_ERC20_MULTIPLIER = 2; -const TEST_RECIPIENT = - '0x87142a49c749dD05069836F9B81E5579E95BE0A6' as `0x${string}`; -const TEST_THRESHOLD = 2; - -type AssetName = 'ETH' | 'ZEN' | 'USDC'; - -interface AssetScenario { - name: AssetName; - isNative: boolean; - tokenAddress: `0x${string}` | null; - decimals: number; -} - -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, - }, -]; - -interface ScenarioAmount { - scenario: AssetScenario; - amountBigInt: bigint; - amountString: string; -} - -type CreatedTx = - | { - kind: 'single'; - scenario: AssetScenario; - amount: ScenarioAmount; - txId: string; - } - | { kind: 'batch'; txId: string }; - -function getCreatedTxLabel(entry: CreatedTx): string { - return entry.kind === 'single' ? entry.scenario.name : 'batch'; -} - -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 }; -} - -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; -} - -interface ParsedBatchItem { - recipient: string; - amount: string; - tokenAddress?: string | null; -} - -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). - */ -async function stagingFundAccountForScenario( - 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, - }; -} + 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, @@ -300,7 +108,7 @@ describe('Transaction Staging E2E', () => { for (const scenario of SCENARIOS) { console.log(`[${scenario.name}] Funding start`); - const funded = await stagingFundAccountForScenario( + const funded = await fundAccountForScenario( scenario, accountAddress, identityA, @@ -454,20 +262,7 @@ describe('Transaction Staging E2E', () => { ); } else { const { to: toApprove, value: valueApprove, callData: callDataApprove } = - (() => { - 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 }; - })(); + buildSingleApproveParams(txDetails); const votePayloadB = await generateVotePayload( identityB, 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, + }; +}